dsh-novel-write-engine 0.2.7 → 0.2.9

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
@@ -15,6 +15,8 @@ const PANEL_ID = "novel-workshop";
15
15
  const PANEL_ROUTE = "/api/novel-write-engine/panel";
16
16
  const SKILL_NAME = "novel-write-engine";
17
17
  const REMOTE_VERSION = 6;
18
+ /** User-visible panel/tutorial version; keep in sync with package.json. */
19
+ const PANEL_VERSION = "0.2.9";
18
20
  /** The panel is a recent-history view; the wire payload and UI use one cap. */
19
21
  const PANEL_CHAPTER_LIMIT = 30;
20
22
  /** Project-relative locations. Every write goes through these constants. */
@@ -308,6 +310,33 @@ function allocateIds(prefix, nextSeq, count) {
308
310
  nextSeq: nextSeq + count
309
311
  };
310
312
  }
313
+ // src/domain/narrator.ts
314
+ /**
315
+ * One narrator-alias contract shared by PG-05, state_delta and lock capture.
316
+ *
317
+ * The plan format is `角色名(第一人称)` or `角色名(第一人称「我」)`. A
318
+ * first-person manuscript says 「我」 instead of repeating the character name;
319
+ * every consumer must accept that alias, and none may infer it from a bare
320
+ * substring such as "林" inside "林砚".
321
+ */
322
+ const narratorName = (narrator) => narrator.split(/[((]/, 1)[0].trim();
323
+ /** The only documented first-person alias is the pronoun itself. */
324
+ const narratorAliases = (narrator) => isFirstPersonNarrator(narrator) ? ["我"] : [];
325
+ /** Whether the plan declares first-person narration. */
326
+ const isFirstPersonNarrator = (narrator) => narrator.includes("第一人称") && !narrator.includes("第三人称");
327
+ /**
328
+ * True when `name` is the declared narrator and the manuscript uses the
329
+ * declared first-person alias. A name that merely contains the narrator name
330
+ * is not an alias.
331
+ */
332
+ const isNarratorAlias = (name, narrator, text) => {
333
+ if (text.includes(name)) return true;
334
+ if (narratorName(narrator) !== name) return false;
335
+ if (!isFirstPersonNarrator(narrator)) return false;
336
+ return narratorAliases(narrator).some((alias) => text.includes(alias));
337
+ };
338
+ /** Paragraph used for a newly locked character; prefers the named/aliased one. */
339
+ const narratorAliasesFor = (name, narrator) => name === narratorName(narrator) ? narratorAliases(narrator) : [];
311
340
  // src/domain/scoring.ts
312
341
  /** The fifteen dimensions, grouped as the commercial weights demand. */
313
342
  const SCORE_DIMENSIONS = [
@@ -1122,7 +1151,7 @@ const dashBudget = (ctx) => {
1122
1151
  return [v(PUNCTUATION_RULES.DASH_BUDGET, "advisory", firstParagraphContaining(ctx.text, token), `dash weight ${weighted} (dialogue ${inDialogue}x double), budget is ${scaled} for ${ctx.sceneType}`)];
1123
1152
  };
1124
1153
  const periodDensity = (ctx) => {
1125
- const { min, max } = ctx.rulepack.punctuation.period_density;
1154
+ const { min, max } = ctx.rulepack.genres[ctx.genre]?.profile?.period_density ?? ctx.rulepack.punctuation.period_density;
1126
1155
  const dominant = ctx.rulepack.punctuation.dialogue_dominant_ratio;
1127
1156
  const results = [];
1128
1157
  splitParagraphs(ctx.text).forEach((paragraph, index) => {
@@ -1355,10 +1384,11 @@ const numericFactMismatch = (ctx) => {
1355
1384
  for (const fact of ctx.facts) {
1356
1385
  if (fact.alias.trim() === "" || !Number.isFinite(fact.value)) continue;
1357
1386
  const tolerance = fact.tolerance ?? 0;
1358
- const pattern = new RegExp(`${escapePattern(fact.alias)}\\s*(?:是|为|约|有|:|:)?\\s*(${NUMBER_TOKEN})\\s*${escapePattern(fact.unit)}`, "g");
1387
+ const pattern = new RegExp(`${escapePattern(fact.alias)}\\s*(?:是|为|约|有|:|:)?\\s*(${NUMBER_TOKEN})\\s*${escapePattern(fact.unit)}(?:\\s*([零一二两三四五六七八九]))?(?![零一二两三四五六七八九十百千万\\d])`, "g");
1359
1388
  for (const match of ctx.text.matchAll(pattern)) {
1360
- const value = parseSmallNumber(match[1] ?? "");
1361
- if (value === null) continue;
1389
+ const base = parseSmallNumber(match[1] ?? "");
1390
+ if (base === null) continue;
1391
+ const value = base + (match[2] === void 0 ? 0 : (CN_DIGITS[match[2]] ?? 0) / 10);
1362
1392
  if (Math.abs(value - fact.value) <= tolerance) continue;
1363
1393
  const paragraph = paragraphs.findIndex((candidate) => candidate.includes(match[0]));
1364
1394
  results.push(v(SEMANTIC_RULES.NUMERIC_FACT_MISMATCH, "advisory", Math.max(1, paragraph + 1), `"${fact.alias}" declares ${fact.value}${fact.unit}, text shows ${value}${fact.unit}`));
@@ -1368,7 +1398,8 @@ const numericFactMismatch = (ctx) => {
1368
1398
  return results;
1369
1399
  };
1370
1400
  const pleasureDensity = (ctx) => {
1371
- const { min, keywords, window } = ctx.rulepack.semantics.pleasure_density;
1401
+ const { keywords, window } = ctx.rulepack.semantics.pleasure_density;
1402
+ const min = ctx.rulepack.genres[ctx.genre]?.profile?.pleasure_density ?? ctx.rulepack.semantics.pleasure_density.min;
1372
1403
  if (keywords.length === 0) return [];
1373
1404
  const results = [];
1374
1405
  if (slidingWindows(ctx.text, window).findIndex((slice) => {
@@ -1385,8 +1416,9 @@ const sensoryDensity = (ctx) => {
1385
1416
  };
1386
1417
  const waterWords = (ctx) => {
1387
1418
  const { window, threshold, terms, scene_thresholds } = ctx.rulepack.semantics.water_words;
1419
+ const genreThreshold = ctx.rulepack.genres[ctx.genre]?.profile?.water_words;
1388
1420
  if (terms.length === 0) return [];
1389
- const sceneThreshold = scene_thresholds?.[ctx.sceneType] ?? threshold;
1421
+ const sceneThreshold = genreThreshold ?? scene_thresholds?.[ctx.sceneType] ?? threshold;
1390
1422
  const results = [];
1391
1423
  for (const slice of slidingWindows(ctx.text, window)) {
1392
1424
  const density = terms.reduce((total, term) => total + countOccurrences(slice, term), 0) / window;
@@ -1554,25 +1586,24 @@ const resourceMismatch = (ctx) => {
1554
1586
  resourceVisible = !(previousHasNumber && previousHasSign) && previousClauseText.includes(change.resource);
1555
1587
  }
1556
1588
  if (!resourceVisible) continue;
1557
- const names = ctx.summary?.characters_appeared ?? [];
1558
- const otherCharacter = names.some((name) => name !== "" && name !== change.character && clauseText.includes(name));
1559
- let characterVisible = clauseText.includes(change.character);
1560
- if (!characterVisible && !otherCharacter) characterVisible = previousClauseText.includes(change.character);
1589
+ const names = [...ctx.summary?.characters_appeared ?? [], ...ctx.evidence.characters?.characters.map((character) => character.name) ?? []];
1590
+ const otherCharacter = mentionedNames$1(clauseText, names.filter((name) => name !== "" && name !== change.character)).length > 0;
1591
+ let characterVisible = mentionedNames$1(clauseText, [change.character]).length > 0 || !otherCharacter && mentionedNames$1(ctx.text.slice(sentence.start, sentence.end), [change.character]).length > 0;
1592
+ if (!characterVisible && !otherCharacter) characterVisible = mentionedNames$1(previousClauseText, [change.character]).length > 0;
1561
1593
  if (!characterVisible && !otherCharacter && sentence.start > 0) {
1562
1594
  const previous = sentenceBounds(ctx.text, sentence.start - 1, sentence.start - 1);
1563
1595
  const previousText = ctx.text.slice(previous.start, previous.end);
1564
- const named = names.filter((name) => previousText.includes(name));
1596
+ const named = mentionedNames$1(previousText, names);
1565
1597
  characterVisible = /(?:他|她|它|我|他们|她们)/.test(clauseText) && named.length === 1 && named[0] === change.character;
1566
1598
  }
1567
1599
  if (!characterVisible) continue;
1568
1600
  conflict = {
1569
1601
  index,
1570
- amount: match[1] ?? "",
1571
- sign: sign[0]
1602
+ trigger: sign[0]
1572
1603
  };
1573
1604
  break;
1574
1605
  }
1575
- return conflict === null ? [] : [v(CROSS_RULES.RESOURCE_MISMATCH, "advisory", paragraphOfIndex(ctx.text, conflict.index), `${change.character}'s ${change.resource} change is ${change.delta >= 0 ? "+" : ""}${change.delta}, but the text states the opposite sign "${conflict.sign}" near amount ${conflict.amount}`)];
1606
+ return conflict === null ? [] : [v(CROSS_RULES.RESOURCE_MISMATCH, "advisory", paragraphOfIndex(ctx.text, conflict.index), `${change.character}'s ${change.resource} change is ${change.delta >= 0 ? "+" : ""}${change.delta}, but the text states the opposite-sign trigger "${conflict.trigger}"`)];
1576
1607
  });
1577
1608
  };
1578
1609
  const timeRegression = (ctx) => {
@@ -1596,6 +1627,31 @@ const timeRegression = (ctx) => {
1596
1627
  if (before === null || current >= before) return [];
1597
1628
  return [v(CROSS_RULES.TIME_REGRESSION, "advisory", last.chapter, `story time goes back from ${previous.story_time} to ${last.story_time}`)];
1598
1629
  };
1630
+ const mentionedNames$1 = (text, names) => {
1631
+ const found = [];
1632
+ for (const name of names) {
1633
+ if (name === "") continue;
1634
+ let index = text.indexOf(name);
1635
+ let present = false;
1636
+ while (index >= 0) {
1637
+ if (!names.some((other) => {
1638
+ if (other.length <= name.length || !other.includes(name)) return false;
1639
+ let otherIndex = text.indexOf(other);
1640
+ while (otherIndex >= 0) {
1641
+ if (index >= otherIndex && index + name.length <= otherIndex + other.length) return true;
1642
+ otherIndex = text.indexOf(other, otherIndex + 1);
1643
+ }
1644
+ return false;
1645
+ })) {
1646
+ present = true;
1647
+ break;
1648
+ }
1649
+ index = text.indexOf(name, index + name.length);
1650
+ }
1651
+ if (present) found.push(name);
1652
+ }
1653
+ return found;
1654
+ };
1599
1655
  const relationUnknown = (ctx) => {
1600
1656
  const characters = ctx.evidence.characters;
1601
1657
  const known = characters === null ? [] : characters.characters.map((character) => character.name);
@@ -1607,7 +1663,7 @@ const relationUnknown = (ctx) => {
1607
1663
  if (names.size < 2) return [];
1608
1664
  const results = [];
1609
1665
  for (const [index, paragraph] of splitParagraphs(ctx.text).entries()) {
1610
- const present = [...names].filter((name) => paragraph.includes(name));
1666
+ const present = mentionedNames$1(paragraph, [...names]);
1611
1667
  for (let first = 0; first < present.length; first += 1) for (let second = first + 1; second < present.length; second += 1) {
1612
1668
  const a = present[first];
1613
1669
  const b = present[second];
@@ -1616,7 +1672,7 @@ const relationUnknown = (ctx) => {
1616
1672
  }
1617
1673
  return results;
1618
1674
  };
1619
- const lastDescribedMention = (name, locked, text, order) => {
1675
+ const describedMentions = (name, locked, text) => {
1620
1676
  const candidates = [];
1621
1677
  const strippedLock = locked.split(name).join("");
1622
1678
  for (const [index, paragraph] of splitParagraphs(text).entries()) {
@@ -1632,9 +1688,9 @@ const lastDescribedMention = (name, locked, text, order) => {
1632
1688
  coverage
1633
1689
  });
1634
1690
  }
1635
- if (candidates.length === 0) return null;
1636
- return order === "first" ? candidates[0] : candidates[candidates.length - 1];
1691
+ return candidates;
1637
1692
  };
1693
+ const bestMention = (mentions) => mentions.reduce((best, mention) => best === null || mention.coverage > best.coverage ? mention : best, null);
1638
1694
  const lockedDetailDrift = (ctx) => {
1639
1695
  if (ctx.evidence.locks === null) return [];
1640
1696
  const locks = ctx.evidence.locks;
@@ -1642,16 +1698,16 @@ const lockedDetailDrift = (ctx) => {
1642
1698
  for (const [name, lock] of Object.entries(locks.locations)) {
1643
1699
  if (!ctx.text.includes(name)) continue;
1644
1700
  if (lock.layout.trim() === "") continue;
1645
- const mention = lastDescribedMention(name, lock.layout, ctx.text, "last");
1701
+ const mention = bestMention(describedMentions(name, lock.layout, ctx.text));
1646
1702
  if (mention === null) continue;
1647
1703
  if (mention.coverage < ctx.rulepack.crosschapter.lock_similarity) results.push(v(CROSS_RULES.LOCKED_DETAIL_DRIFT, "advisory", mention.index + 1, `${name} is described differently from its locked layout; expected "${lock.layout.slice(0, 40)}", actual "${mention.text.slice(0, 60)}"`));
1648
1704
  }
1649
1705
  for (const [name, lock] of Object.entries(locks.characters)) {
1650
1706
  if (!ctx.text.includes(name)) continue;
1651
1707
  if (lock.appearance.trim() === "") continue;
1652
- const mention = lastDescribedMention(name, lock.appearance, ctx.text, "first");
1708
+ const mention = bestMention(describedMentions(name, lock.appearance, ctx.text));
1653
1709
  if (mention === null) continue;
1654
- if (mention.coverage < ctx.rulepack.crosschapter.lock_similarity) results.push(v(CROSS_RULES.LOCKED_DETAIL_DRIFT, "advisory", mention.index + 1, `${name} is described differently from the locked first appearance; expected "${lock.appearance.slice(0, 40)}", actual "${mention.text.slice(0, 60)}"`));
1710
+ if (mention.coverage < ctx.rulepack.crosschapter.lock_similarity) results.push(v(CROSS_RULES.LOCKED_DETAIL_DRIFT, "advisory", mention.index + 1, `${name} is described differently from its locked appearance; expected "${lock.appearance.slice(0, 40)}", actual "${mention.text.slice(0, 60)}"`));
1655
1711
  }
1656
1712
  return results;
1657
1713
  };
@@ -2138,6 +2194,23 @@ const validateSummary = (kind, value, path) => {
2138
2194
  numberAt(kind, summary.hook_strength, `${path}.hook_strength`);
2139
2195
  stringAt(kind, summary.plot_advancement, `${path}.plot_advancement`);
2140
2196
  };
2197
+ const validateArchivedCard = (kind, value, path) => {
2198
+ if (value === void 0) return;
2199
+ const card = objectAt(kind, value, path);
2200
+ stringAt(kind, card.title, `${path}.title`);
2201
+ stringAt(kind, card.scene_type, `${path}.scene_type`);
2202
+ const target = listAt(kind, card.target_words, `${path}.target_words`);
2203
+ if (target.length !== 2) invalid$2(kind, `${path}.target_words must contain two numbers`);
2204
+ for (const [index, item] of target.entries()) numberAt(kind, item, `${path}.target_words[${index}]`);
2205
+ for (const [index, item] of listAt(kind, card.cast, `${path}.cast`).entries()) stringAt(kind, item, `${path}.cast[${index}]`);
2206
+ for (const key of [
2207
+ "goal",
2208
+ "conflict",
2209
+ "hook",
2210
+ "notes"
2211
+ ]) stringAt(kind, card[key], `${path}.${key}`);
2212
+ nullableString(kind, card.archived_at, `${path}.archived_at`);
2213
+ };
2141
2214
  const validateNested = (kind, record) => {
2142
2215
  switch (kind) {
2143
2216
  case "project": {
@@ -2186,6 +2259,8 @@ const validateNested = (kind, record) => {
2186
2259
  validateSummary(kind, row.summary, `${path}.summary`);
2187
2260
  if (row.assigned_ids !== void 0) objectAt(kind, row.assigned_ids, `${path}.assigned_ids`);
2188
2261
  optionalString(kind, row.archive_digest, `${path}.archive_digest`);
2262
+ optionalString(kind, row.archive_card_digest, `${path}.archive_card_digest`);
2263
+ validateArchivedCard(kind, row.archive_card, `${path}.archive_card`);
2189
2264
  optionalString(kind, row.final_check, `${path}.final_check`);
2190
2265
  optionalString(kind, row.archived_at, `${path}.archived_at`);
2191
2266
  if (row.rounds !== void 0) {
@@ -2528,7 +2603,13 @@ var FsStore = class {
2528
2603
  /** Absolute path of an internal file outside the project root (locks live at root). */
2529
2604
  async ensureDir(relativePath) {
2530
2605
  const absolute = await this.safePath(relativePath);
2531
- await promises.mkdir(absolute, { recursive: true });
2606
+ await this.ensureParent(absolute);
2607
+ try {
2608
+ await promises.mkdir(absolute);
2609
+ } catch (error) {
2610
+ if (error.code !== "EEXIST") throw ioFailure(error);
2611
+ 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");
2612
+ }
2532
2613
  return absolute;
2533
2614
  }
2534
2615
  async exists(relativePath) {
@@ -3163,13 +3244,23 @@ var FileLock = class {
3163
3244
  if (record === null) {
3164
3245
  if (Date.now() - mtimeMs <= this.staleMs) return false;
3165
3246
  } else if (lockRecordIsLive(record)) return false;
3166
- const claimPath = `${this.absolutePath}.reclaim-${randomUUID()}`;
3247
+ const claimPath = `${this.absolutePath}.reclaim`;
3167
3248
  let claimByRename = false;
3168
3249
  try {
3169
3250
  await promises.link(this.absolutePath, claimPath);
3170
3251
  } catch (error) {
3171
3252
  const code = error.code;
3172
3253
  if (code === "ENOENT") return true;
3254
+ if (code === "EEXIST") {
3255
+ const claimMtime = await promises.stat(claimPath).then((info) => {
3256
+ return info.nlink > 1 ? info.ctimeMs : info.mtimeMs;
3257
+ }).catch(() => Date.now());
3258
+ if (Date.now() - claimMtime > this.staleMs) {
3259
+ await promises.rm(claimPath, { force: true }).catch(() => void 0);
3260
+ return true;
3261
+ }
3262
+ return false;
3263
+ }
3173
3264
  if (code === "EPERM" || code === "EACCES") return false;
3174
3265
  if ([
3175
3266
  "ENOSYS",
@@ -3218,7 +3309,9 @@ const BACKUP_MANIFEST = "manifest.json";
3218
3309
  /** Backup transaction directories, newest first by filesystem mtime. */
3219
3310
  async function listBackups(store) {
3220
3311
  const entries = [];
3221
- for (const name of await store.list(BACKUP_DIR)) {
3312
+ for (const entry of await store.entries(BACKUP_DIR)) {
3313
+ if (!entry.directory || entry.symbolicLink) continue;
3314
+ const name = entry.name;
3222
3315
  if (name === "migrations") continue;
3223
3316
  const info = await store.stat(joinRel(BACKUP_DIR, name));
3224
3317
  if (info === null || !info.isDirectory) continue;
@@ -3379,6 +3472,7 @@ async function runTransaction(store, input) {
3379
3472
  for (const file of input.files) {
3380
3473
  const existing = before.get(file.path);
3381
3474
  if (existing === null || existing === void 0) continue;
3475
+ await assertRootPresent();
3382
3476
  await store.writeBytes(joinRel(staging, "before", file.path), existing);
3383
3477
  }
3384
3478
  if (input.backup) {
@@ -3386,8 +3480,10 @@ async function runTransaction(store, input) {
3386
3480
  for (const file of input.files) {
3387
3481
  const existing = before.get(file.path);
3388
3482
  if (existing === null || existing === void 0) continue;
3483
+ await assertRootPresent();
3389
3484
  await store.writeBytes(joinRel(stagingBackup, file.path), existing);
3390
3485
  }
3486
+ await assertRootPresent();
3391
3487
  await store.writeText(joinRel(stagingBackup, BACKUP_MARKER), "1");
3392
3488
  await store.writeText(joinRel(stagingBackup, BACKUP_MANIFEST), `${JSON.stringify({
3393
3489
  schema_version: 1,
@@ -3399,9 +3495,11 @@ async function runTransaction(store, input) {
3399
3495
  ...input.reason === void 0 ? {} : { reason: input.reason },
3400
3496
  files: input.files.filter((file) => before.get(file.path) !== null && before.get(file.path) !== void 0).map((file) => file.path)
3401
3497
  }, 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);
3498
+ await assertRootPresent();
3499
+ await store.ensureDir(BACKUP_DIR);
3500
+ await assertRootPresent();
3501
+ const targetBackup = await store.safePath(backupDir(input.txid));
3502
+ await promises.rename(await store.safePath(stagingBackup), targetBackup);
3405
3503
  await syncDirectory(dirname(targetBackup));
3406
3504
  backupMarked = true;
3407
3505
  backupComplete = true;
@@ -4124,7 +4222,7 @@ function outlineSummary(document, archivedCount, target = null, archivedNumbers)
4124
4222
  missingCards,
4125
4223
  plannedCoverage,
4126
4224
  archivedCoverage,
4127
- coverage: plannedCoverage ?? (archivedDenominator === 0 ? 0 : cardCoverage),
4225
+ coverage: plannedCoverage ?? cardCoverage,
4128
4226
  cardCoverage,
4129
4227
  volumes: document.volumes.length
4130
4228
  };
@@ -4305,12 +4403,38 @@ function normalizeOutlineChapters(input, options) {
4305
4403
  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
4404
  const cast = raw.cast === void 0 ? base.cast : stringArray$1(raw.cast, `chapters[${index}].cast`, 8);
4307
4405
  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");
4406
+ const goal = raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$2(raw.goal, `chapter ${num} goal`);
4407
+ const conflict = raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$2(raw.conflict, `chapter ${num} conflict`);
4408
+ const hook = raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$2(raw.hook, `chapter ${num} hook`);
4409
+ const notes = raw.notes === void 0 || raw.notes === base.notes ? base.notes : optionalText(raw.notes, `chapter ${num} notes`, LIMITS.maxOutlineNotesChars);
4410
+ for (const [field, value, previous] of [
4411
+ [
4412
+ "goal",
4413
+ goal,
4414
+ base.goal
4415
+ ],
4416
+ [
4417
+ "conflict",
4418
+ conflict,
4419
+ base.conflict
4420
+ ],
4421
+ [
4422
+ "hook",
4423
+ hook,
4424
+ base.hook
4425
+ ],
4426
+ [
4427
+ "notes",
4428
+ notes,
4429
+ base.notes
4430
+ ]
4431
+ ]) 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
4432
  normalized.set(num, {
4309
4433
  ...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),
4434
+ goal,
4435
+ conflict,
4436
+ hook,
4437
+ notes,
4314
4438
  volume: volumeForChapter(options.volumes, num, raw.volume === void 0 ? base.volume : raw.volume),
4315
4439
  status: "archived",
4316
4440
  archived_at: base.archived_at ?? record.archived_at ?? null,
@@ -4718,7 +4842,7 @@ const pick = (values, mentioned, key, limit) => {
4718
4842
  return [...matched, ...rest].slice(0, limit);
4719
4843
  };
4720
4844
  /** Protect the brief budget from a legal-but-huge planning document. */
4721
- const clip = (value, limit) => {
4845
+ const clip$1 = (value, limit) => {
4722
4846
  const chars = Array.from(value);
4723
4847
  return chars.length <= limit ? value : `${chars.slice(0, limit).join("")}…`;
4724
4848
  };
@@ -4727,44 +4851,44 @@ function worldbookBrief(document, mentioned, names) {
4727
4851
  const named = names.join("\n");
4728
4852
  const characters = pick(document.characters, named === "" ? mentioned : named, (entry) => entry.name, 8);
4729
4853
  return {
4730
- premise: clip(document.premise, 600),
4731
- setting: clip(document.setting, 600),
4732
- themes: document.themes.slice(0, 12).map((theme) => clip(theme, 80)),
4854
+ premise: clip$1(document.premise, 600),
4855
+ setting: clip$1(document.setting, 600),
4856
+ themes: document.themes.slice(0, 12).map((theme) => clip$1(theme, 80)),
4733
4857
  characters: characters.map((entry) => ({
4734
4858
  name: entry.name,
4735
4859
  role: entry.role,
4736
- description: clip(entry.description, 240),
4737
- arc: clip(entry.arc, 160)
4860
+ description: clip$1(entry.description, 240),
4861
+ arc: clip$1(entry.arc, 160)
4738
4862
  })),
4739
4863
  locations: pick(document.locations, mentioned, (entry) => entry.name, 6).map((entry) => ({
4740
4864
  name: entry.name,
4741
- description: clip(entry.description, 240),
4742
- layout: clip(entry.layout, 120),
4865
+ description: clip$1(entry.description, 240),
4866
+ layout: clip$1(entry.layout, 120),
4743
4867
  capacity: entry.capacity
4744
4868
  })),
4745
4869
  factions: pick(document.factions, mentioned, (entry) => entry.name, 6).map((entry) => ({
4746
4870
  name: entry.name,
4747
- description: clip(entry.description, 240),
4748
- goal: clip(entry.goal, 120)
4871
+ description: clip$1(entry.description, 240),
4872
+ goal: clip$1(entry.goal, 120)
4749
4873
  })),
4750
4874
  rules: pick(document.rules, mentioned, (entry) => entry.name, 6).map((entry) => ({
4751
4875
  name: entry.name,
4752
- description: clip(entry.description, 240)
4876
+ description: clip$1(entry.description, 240)
4753
4877
  })),
4754
4878
  glossary: pick(document.glossary, mentioned, (entry) => entry.term, 6).map((entry) => ({
4755
4879
  term: entry.term,
4756
- definition: clip(entry.definition, 160)
4880
+ definition: clip$1(entry.definition, 160)
4757
4881
  })),
4758
4882
  backstory: document.backstory.slice(0, 3).map((entry) => ({
4759
4883
  time: entry.time,
4760
- event: clip(entry.event, 160)
4884
+ event: clip$1(entry.event, 160)
4761
4885
  })),
4762
4886
  facts: (document.facts ?? []).slice(0, 20).map((fact) => ({
4763
- alias: clip(fact.alias, 120),
4887
+ alias: clip$1(fact.alias, 120),
4764
4888
  value: fact.value,
4765
- unit: clip(fact.unit, 40),
4889
+ unit: clip$1(fact.unit, 40),
4766
4890
  tolerance: fact.tolerance ?? 0,
4767
- ...fact.note === void 0 ? {} : { note: clip(fact.note, 160) }
4891
+ ...fact.note === void 0 ? {} : { note: clip$1(fact.note, 160) }
4768
4892
  }))
4769
4893
  };
4770
4894
  }
@@ -4772,14 +4896,14 @@ function worldbookBrief(document, mentioned, names) {
4772
4896
  function bookBrief(document, chapter) {
4773
4897
  const volume = document.volumes.find((candidate) => chapter >= candidate.start_chapter && chapter <= candidate.end_chapter) ?? null;
4774
4898
  return {
4775
- premise: clip(document.book.premise, 600),
4776
- logline: clip(document.book.logline, 600),
4777
- themes: document.book.themes.slice(0, 12).map((theme) => clip(theme, 80)),
4778
- tone: clip(document.book.tone, 200),
4779
- pov: clip(document.book.pov, 200),
4780
- audience: clip(document.book.audience, 200),
4781
- selling_points: document.book.selling_points.slice(0, 12).map((point) => clip(point, 120)),
4782
- ending: clip(document.book.ending, 400),
4899
+ premise: clip$1(document.book.premise, 600),
4900
+ logline: clip$1(document.book.logline, 600),
4901
+ themes: document.book.themes.slice(0, 12).map((theme) => clip$1(theme, 80)),
4902
+ tone: clip$1(document.book.tone, 200),
4903
+ pov: clip$1(document.book.pov, 200),
4904
+ audience: clip$1(document.book.audience, 200),
4905
+ selling_points: document.book.selling_points.slice(0, 12).map((point) => clip$1(point, 120)),
4906
+ ending: clip$1(document.book.ending, 400),
4783
4907
  strict: document.book.strict,
4784
4908
  volume,
4785
4909
  card: outlineCard(document, chapter)
@@ -4853,9 +4977,11 @@ function parseOutlineMarkdown(content) {
4853
4977
  if (currentVolume === null) return;
4854
4978
  const volume = currentVolume;
4855
4979
  const chapters = chapterInputs.filter((card) => card.volume === volume.order);
4856
- if (chapters.length > 0) {
4980
+ if (chapters.length > 0 && !volume.explicitRange) {
4857
4981
  volume.start_chapter = Math.min(volume.start_chapter, ...chapters.map((card) => card.num));
4858
4982
  volume.end_chapter = Math.max(volume.end_chapter, ...chapters.map((card) => card.num));
4983
+ } else if (chapters.length > 0) {
4984
+ for (const card of chapters) if (card.num < volume.start_chapter || card.num > volume.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `Markdown volume "${volume.title}" declares 第${volume.start_chapter}-${volume.end_chapter}章 but contains chapter ${card.num}`, "fix the declared range or move the card into the right volume");
4859
4985
  } else if (!volume.explicitRange) {
4860
4986
  warnings.push(`volume "${volume.title}" has neither a chapter range nor chapter cards; it was ignored`);
4861
4987
  volumeInputs.pop();
@@ -5138,6 +5264,7 @@ const mergeVolumes = (options) => {
5138
5264
  }
5139
5265
  }
5140
5266
  const byTitle = volumeByTitle(options.existingVolumes);
5267
+ const byId = new Map(options.existingVolumes.map((volume) => [volume.id, volume]));
5141
5268
  const usedIds = new Set(options.existingVolumes.map((volume) => volume.id));
5142
5269
  let nextGeneratedId = 1;
5143
5270
  const generatedId = () => {
@@ -5148,21 +5275,39 @@ const mergeVolumes = (options) => {
5148
5275
  return id;
5149
5276
  };
5150
5277
  const submitted = /* @__PURE__ */ new Set();
5278
+ const submittedIds = /* @__PURE__ */ new Set();
5279
+ const generatedIds = /* @__PURE__ */ new Set();
5151
5280
  for (const [index, raw] of (options.volumes ?? []).entries()) {
5152
5281
  if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
5153
5282
  const title = text$1(raw.title, `volumes[${index}].title`);
5154
5283
  if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains duplicate title "${title}"`);
5155
5284
  submitted.add(title);
5156
- const base = byTitle.get(title);
5157
5285
  const explicitId = typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : void 0;
5158
- const id = base?.id ?? explicitId ?? generatedId();
5286
+ if (explicitId !== void 0 && (submittedIds.has(explicitId) || generatedIds.has(explicitId))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains duplicate id "${explicitId}"`);
5287
+ if (explicitId !== void 0) submittedIds.add(explicitId);
5288
+ const byIdBase = explicitId === void 0 ? void 0 : byId.get(explicitId);
5289
+ const titleBase = byTitle.get(title);
5290
+ if (explicitId !== void 0 && byIdBase === void 0 && usedIds.has(explicitId)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume id "${explicitId}" already belongs to another volume`);
5291
+ if (explicitId !== void 0 && titleBase !== void 0 && titleBase.id !== explicitId) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${title}" cannot change its stable id from "${titleBase.id}" to "${explicitId}"`);
5292
+ const base = byIdBase ?? titleBase;
5293
+ let id;
5294
+ if (base !== void 0) id = base.id;
5295
+ else if (explicitId !== void 0) id = explicitId;
5296
+ else {
5297
+ id = generatedId();
5298
+ generatedIds.add(id);
5299
+ }
5300
+ if (base !== void 0 && explicitId !== void 0 && base.id !== explicitId) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${base.title}" cannot change its stable id from "${base.id}" to "${explicitId}"`);
5301
+ if (base !== void 0 && base.title !== title) byTitle.delete(base.title);
5159
5302
  if (!usedIds.has(id)) usedIds.add(id);
5160
- byTitle.set(title, {
5303
+ const next = {
5161
5304
  ...base ?? {},
5162
5305
  ...raw,
5163
5306
  id,
5164
5307
  title
5165
- });
5308
+ };
5309
+ byTitle.set(title, next);
5310
+ byId.set(id, next);
5166
5311
  }
5167
5312
  for (const title of removed) {
5168
5313
  if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${title}" cannot be both submitted and removed`);
@@ -5170,6 +5315,7 @@ const mergeVolumes = (options) => {
5170
5315
  if (existing === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${title}": it is not in the plan`);
5171
5316
  if (existing.status === "done") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${title}": its status is done`, "a completed volume is archive evidence; remove only planned/writing volumes");
5172
5317
  byTitle.delete(title);
5318
+ byId.delete(existing.id);
5173
5319
  }
5174
5320
  const volumes = normalizeVolumes([...byTitle.values()], { totalChapters: options.totalChapters });
5175
5321
  const before = volumeByTitle(options.existingVolumes);
@@ -5485,9 +5631,10 @@ const requireForeshadowFields = (value, where) => {
5485
5631
  if (value.note !== void 0 && typeof value.note !== "string") fail$1(`${where}.note must be a string`);
5486
5632
  if (value.new_info !== void 0 && typeof value.new_info !== "boolean") fail$1(`${where}.new_info must be a boolean`);
5487
5633
  };
5634
+ const withoutQuotedSpeech = (text) => text.replace(/"[^"]*"|“[^”]*”|「[^」]*」|『[^』]*』/g, "");
5488
5635
  const appearanceOf = (text, name, aliases = []) => {
5489
5636
  const paragraphs = splitParagraphs(text);
5490
- const source = paragraphs.find((paragraph) => paragraph.includes(name) || aliases.some((alias) => alias !== "" && paragraph.includes(alias))) ?? paragraphs[0] ?? "";
5637
+ 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
5638
  return Array.from(source).slice(0, 120).join("");
5492
5639
  };
5493
5640
  const named = (value) => isRecord(value) && typeof value.name === "string";
@@ -5578,12 +5725,8 @@ function applyStateDelta(input) {
5578
5725
  const appeared = [...new Set(summary.characters_appeared)];
5579
5726
  const byName = new Map(characters.characters.map((character) => [character.name, character]));
5580
5727
  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
- };
5728
+ const narratorName$1 = narratorName(narrator);
5729
+ const appearsInManuscript = (name) => isNarratorAlias(name, narrator, input.text);
5587
5730
  for (const name of appeared) {
5588
5731
  if (!appearsInManuscript(name)) fail$1(`character "${name}" is listed in characters_appeared but does not appear in the manuscript`);
5589
5732
  if (!Object.hasOwn(delta.characters, name)) fail$1(`character "${name}" appeared in the chapter but is not declared in characters`);
@@ -5605,16 +5748,20 @@ function applyStateDelta(input) {
5605
5748
  if (existing !== void 0) fail$1(`character "${name}" already exists; use updated or no_change`);
5606
5749
  if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
5607
5750
  const rawHint = input.roleHints?.[name];
5608
- const hinted = canonicalRole(rawHint) ?? (rawHint === void 0 && (name === narratorName || name === narrator) ? "protagonist" : "extra");
5751
+ const hinted = canonicalRole(rawHint) ?? (rawHint === void 0 && (name === narratorName$1 || name === narrator) ? "protagonist" : "extra");
5609
5752
  const currentState = declared.current_state ?? {};
5610
5753
  validateResources(currentState, `characters.${name}`);
5754
+ const growth = declared.growth === void 0 ? [] : [{
5755
+ ...declared.growth,
5756
+ chapter
5757
+ }];
5611
5758
  const created = {
5612
5759
  name,
5613
5760
  role: normalizeRole(declared.role, hinted),
5614
5761
  first_appearance: chapter,
5615
5762
  profile: declared.profile ?? {},
5616
5763
  current_state: currentState,
5617
- growth: [],
5764
+ growth,
5618
5765
  updated_at: chapter
5619
5766
  };
5620
5767
  characters.characters.push(created);
@@ -5626,7 +5773,7 @@ function applyStateDelta(input) {
5626
5773
  const rawHint = input.roleHints?.[name];
5627
5774
  const hinted = canonicalRole(rawHint);
5628
5775
  if (hinted !== null && hinted !== "extra") existing.role = hinted;
5629
- else if (rawHint === void 0 && (name === narratorName || name === narrator)) existing.role = "protagonist";
5776
+ else if (rawHint === void 0 && (name === narratorName$1 || name === narrator)) existing.role = "protagonist";
5630
5777
  }
5631
5778
  if (declared.role !== void 0) existing.role = normalizeRole(declared.role, existing.role);
5632
5779
  if (declared.action === "updated") {
@@ -5809,7 +5956,7 @@ function applyStateDelta(input) {
5809
5956
  setOwn(locks.characters, name, {
5810
5957
  first_appearance: character.first_appearance,
5811
5958
  locked_at: chapter,
5812
- appearance: appearanceOf(input.text, name, name === narratorName ? narratorAliases : [])
5959
+ appearance: appearanceOf(input.text, name, narratorAliasesFor(name, narrator))
5813
5960
  });
5814
5961
  }
5815
5962
  for (const location of world.locations) if (!Object.hasOwn(locks.locations, location.name)) setOwn(locks.locations, location.name, {
@@ -6004,6 +6151,11 @@ function applyStateDelta(input) {
6004
6151
  }
6005
6152
  // src/engine/insight.ts
6006
6153
  const average = (values) => values.length === 0 ? 0 : values.reduce((total, value) => total + value, 0) / values.length;
6154
+ const clip = (value, max) => {
6155
+ const chars = Array.from(value);
6156
+ return chars.length <= max ? value : `${chars.slice(0, Math.max(0, max - 1)).join("")}…`;
6157
+ };
6158
+ const clipList = (values, max) => values.map((value) => clip(value, max));
6007
6159
  const median = (values) => {
6008
6160
  if (values.length === 0) return 0;
6009
6161
  const sorted = [...values].sort((left, right) => left - right);
@@ -6051,7 +6203,7 @@ function buildBookReport(input) {
6051
6203
  }
6052
6204
  const chapterReviews = [...finalReviews.values()];
6053
6205
  const qualitySamples = chapterReviews.length;
6054
- const qualityAttempts = Math.max(input.reviewAttempts ?? input.reviews.length, qualitySamples);
6206
+ const qualityAttempts = input.reviewAttempts ?? input.reviews.length;
6055
6207
  const trendBasis = qualitySamples < 3 ? "insufficient_samples" : previous.length < 5 ? "insufficient_history" : "last_five_vs_previous_five";
6056
6208
  const trend = trendBasis === "last_five_vs_previous_five" ? Math.round((average(lastFive) - average(previous)) * 10) / 10 : null;
6057
6209
  const dimensions = /* @__PURE__ */ new Map();
@@ -6156,7 +6308,7 @@ function buildBookReport(input) {
6156
6308
  target: input.targetChapters,
6157
6309
  words,
6158
6310
  average_words: averageWords,
6159
- percent: input.targetChapters === null || input.targetChapters === 0 ? 0 : Math.min(100, Math.round(records.length / input.targetChapters * 1e3) / 10),
6311
+ percent: input.targetChapters === null || input.targetChapters === 0 ? null : Math.min(100, Math.round(records.length / input.targetChapters * 1e3) / 10),
6160
6312
  remaining_words: averageRemaining,
6161
6313
  remaining_words_min: minimumRemaining,
6162
6314
  remaining_words_average: averageRemaining,
@@ -6194,7 +6346,8 @@ function buildBookReport(input) {
6194
6346
  dimensions: [...dimensions.entries()].map(([id, value]) => ({
6195
6347
  id,
6196
6348
  average: Math.round(value.total / value.samples * 10) / 10,
6197
- samples: value.samples
6349
+ samples: value.samples,
6350
+ max: input.dimensionMax?.[id] ?? 0
6198
6351
  })).sort((left, right) => compareText(left.id, right.id))
6199
6352
  },
6200
6353
  effort: {
@@ -6330,8 +6483,8 @@ function buildGuide(input) {
6330
6483
  },
6331
6484
  {
6332
6485
  id: "write",
6333
- title: input.finished ? "全书章节已写完" : `写第 ${input.chapter} 章`,
6334
- detail: input.finished ? `已完成 ${input.archived} 章;下一步是导出成书。` : `当前阶段 ${input.stage};${input.blocking === 0 ? "没有阻塞" : `${input.blocking} 条阻塞`}。`,
6486
+ 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} 章`,
6487
+ detail: input.finished ? `已完成 ${input.archived} 章;下一步是导出成书。` : `${input.progress?.label ?? `当前阶段 ${input.stage}`};${input.blocking === 0 ? "没有阻塞" : `${input.blocking} 条阻塞`}。`,
6335
6488
  done: input.finished,
6336
6489
  tool: input.next.tool
6337
6490
  },
@@ -6345,7 +6498,7 @@ function buildGuide(input) {
6345
6498
  ];
6346
6499
  return {
6347
6500
  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}`,
6501
+ title: input.finished ? "全书已完成" : input.progress?.label ?? `第 ${input.chapter} 章 · ${input.stage}`,
6349
6502
  steps,
6350
6503
  next: input.next,
6351
6504
  commands: [
@@ -6602,13 +6755,13 @@ const ledgerWorldEntries = (world, kind) => {
6602
6755
  const nameDescription = (entries, world, kind = "location") => {
6603
6756
  const merged = /* @__PURE__ */ new Map();
6604
6757
  for (const entry of [...entries, ...ledgerWorldEntries(world, kind)]) {
6605
- const name = oneLine(entry.name);
6758
+ const name = clip(oneLine(entry.name), 80);
6606
6759
  if (name === "") continue;
6607
6760
  const existing = merged.get(name);
6608
6761
  if (existing === void 0) {
6609
6762
  merged.set(name, {
6610
6763
  name,
6611
- description: oneLine(entry.description),
6764
+ description: clip(oneLine(entry.description), 240),
6612
6765
  ...entry.anchors === void 0 || entry.anchors.length === 0 ? {} : { anchors: [...entry.anchors] }
6613
6766
  });
6614
6767
  continue;
@@ -6628,12 +6781,12 @@ function buildPanelBookData(input) {
6628
6781
  const card = input.outline.chapters.find((candidate) => candidate.num === input.currentChapter);
6629
6782
  const currentCard = card === void 0 ? null : {
6630
6783
  num: card.num,
6631
- title: card.title,
6784
+ title: clip(card.title, 120),
6632
6785
  scene_type: card.scene_type,
6633
- goal: card.goal,
6634
- conflict: card.conflict,
6635
- hook: card.hook,
6636
- cast: [...card.cast],
6786
+ goal: clip(card.goal, 400),
6787
+ conflict: clip(card.conflict, 400),
6788
+ hook: clip(card.hook, 400),
6789
+ cast: [...card.cast].slice(0, 8).map((name) => clip(name, 80)),
6637
6790
  target_words: [card.target_words[0], card.target_words[1]],
6638
6791
  status: card.status
6639
6792
  };
@@ -6650,8 +6803,8 @@ function buildPanelBookData(input) {
6650
6803
  };
6651
6804
  return {
6652
6805
  identity: {
6653
- author: input.project.author,
6654
- description: input.project.description,
6806
+ author: clip(input.project.author, 80),
6807
+ description: clip(input.project.description, 400),
6655
6808
  created_at: input.project.created_at,
6656
6809
  targets: {
6657
6810
  words_per_chapter: [input.project.targets.words_per_chapter[0], input.project.targets.words_per_chapter[1]],
@@ -6660,14 +6813,14 @@ function buildPanelBookData(input) {
6660
6813
  }
6661
6814
  },
6662
6815
  blueprint: {
6663
- premise: input.outline.book.premise,
6664
- logline: input.outline.book.logline,
6665
- themes: [...input.outline.book.themes],
6666
- tone: input.outline.book.tone,
6667
- pov: input.outline.book.pov,
6668
- audience: input.outline.book.audience,
6669
- selling_points: [...input.outline.book.selling_points],
6670
- ending: input.outline.book.ending
6816
+ premise: clip(input.outline.book.premise, 400),
6817
+ logline: clip(input.outline.book.logline, 400),
6818
+ themes: clipList(input.outline.book.themes, 120).slice(0, 20),
6819
+ tone: clip(input.outline.book.tone, 400),
6820
+ pov: clip(input.outline.book.pov, 400),
6821
+ audience: clip(input.outline.book.audience, 400),
6822
+ selling_points: clipList(input.outline.book.selling_points, 200).slice(0, 20),
6823
+ ending: clip(input.outline.book.ending, 400)
6671
6824
  },
6672
6825
  progress: {
6673
6826
  archived: report.progress.archived,
@@ -6693,9 +6846,9 @@ function buildPanelBookData(input) {
6693
6846
  factions: nameDescription(input.worldbook.factions, input.world, "faction"),
6694
6847
  rules: nameDescription(input.worldbook.rules, input.world, "rule"),
6695
6848
  facts: (input.worldbook.facts ?? []).slice(0, 5).map((fact) => ({
6696
- alias: fact.alias,
6849
+ alias: clip(fact.alias, 80),
6697
6850
  value: fact.value,
6698
- unit: fact.unit
6851
+ unit: clip(fact.unit, 40)
6699
6852
  }))
6700
6853
  },
6701
6854
  cast: {
@@ -6738,11 +6891,11 @@ function buildPanelBookData(input) {
6738
6891
  health: {
6739
6892
  state_errors: input.stateErrors.slice(0, 10).map((entry) => ({ ...entry })),
6740
6893
  outline: {
6741
- present: input.outlinePresent,
6894
+ present: input.outlinePresent && input.outlineError === null,
6742
6895
  error: input.outlineError
6743
6896
  },
6744
6897
  worldbook: {
6745
- present: input.worldbookPresent,
6898
+ present: input.worldbookPresent && input.worldbookError === null,
6746
6899
  error: input.worldbookError
6747
6900
  },
6748
6901
  pending: input.pending === null ? null : { ...input.pending }
@@ -6762,7 +6915,7 @@ const kindOf = (directory, suffix) => {
6762
6915
  * still on disk: history is evidence, not a cache, and this function never
6763
6916
  * deletes it.
6764
6917
  */
6765
- async function listHistory(store, chapter) {
6918
+ async function listHistory(store, chapter, options = {}) {
6766
6919
  const entries = [];
6767
6920
  for (const kind of HISTORY_KINDS) {
6768
6921
  const names = await store.list(joinRel(HISTORY_DIR, kind));
@@ -6775,14 +6928,14 @@ async function listHistory(store, chapter) {
6775
6928
  const revision = Number(match.groups.revision);
6776
6929
  if (chapter !== void 0 && num !== chapter) continue;
6777
6930
  const path = joinRel(HISTORY_DIR, kind, name);
6778
- const text = await store.readText(path);
6779
- entries.push({
6931
+ const entry = {
6780
6932
  kind,
6781
6933
  chapter: num,
6782
6934
  revision,
6783
- path,
6784
- chars: text?.length ?? 0
6785
- });
6935
+ path
6936
+ };
6937
+ if (options.withChars === true) entry.chars = (await store.readText(path))?.length ?? 0;
6938
+ entries.push(entry);
6786
6939
  }
6787
6940
  }
6788
6941
  return entries.sort((left, right) => left.chapter - right.chapter || right.revision - left.revision || compareText(left.kind, right.kind));
@@ -7957,7 +8110,7 @@ const SUMMARY_FIELDS = [
7957
8110
  "plot_advancement"
7958
8111
  ];
7959
8112
  /** PG-05: the ten summary fields exist and have the right shape. */
7960
- const summarySchema = (input) => {
8113
+ const summarySchema$1 = (input) => {
7961
8114
  if (input.operation === "submitRevision" && input.summaryProvided !== true) return [];
7962
8115
  if (input.summary === null) return [block("PG-05", "no chapter summary supplied")];
7963
8116
  const summary = input.summary;
@@ -8015,11 +8168,8 @@ const summarySchema = (input) => {
8015
8168
  }
8016
8169
  }
8017
8170
  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
- }
8171
+ const narrator = input.narrator ?? input.pipeline.draft?.narrator ?? "";
8172
+ 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
8173
  }
8024
8174
  const resources = summary.resource_changes;
8025
8175
  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");
@@ -8252,7 +8402,7 @@ const GATE_FUNCTIONS = {
8252
8402
  "PG-02": stageOrder,
8253
8403
  "PG-03": wordCount,
8254
8404
  "PG-04": format,
8255
- "PG-05": summarySchema,
8405
+ "PG-05": summarySchema$1,
8256
8406
  "PG-06": sceneType,
8257
8407
  "PG-07": score,
8258
8408
  "PG-08": issuesCleared,
@@ -8363,12 +8513,67 @@ function briefStageFor(operation, stage) {
8363
8513
  }
8364
8514
  }
8365
8515
  // src/engine/projections.ts
8516
+ /**
8517
+ * Turn the protocol pair (`chapter` + `stage`) into the only human-readable
8518
+ * chapter boundary. After archiving chapter N the protocol intentionally keeps
8519
+ * `stage: 'done'` and advances `chapter` to N+1; `done` describes chapter N,
8520
+ * not the chapter the pipeline now points at.
8521
+ */
8522
+ function projectChapterProgress(input) {
8523
+ const archived = [...new Set(input.archived)].filter((num) => Number.isInteger(num) && num > 0).sort((left, right) => left - right);
8524
+ const lastArchived = archived.length === 0 ? null : archived[archived.length - 1];
8525
+ const finished = input.stage === "done" && input.target !== null && archived.length >= input.target;
8526
+ const base = {
8527
+ active_chapter: input.activeChapter,
8528
+ stage: input.stage,
8529
+ last_archived: lastArchived,
8530
+ finished
8531
+ };
8532
+ if (finished) return {
8533
+ ...base,
8534
+ current: null,
8535
+ next: null,
8536
+ label: `全书完成:已归档 ${archived.length} 章`,
8537
+ resume: `全书已完成(已归档 ${archived.length} 章);如需续写,先用 novel_amend 提高 targets.total_chapters。`
8538
+ };
8539
+ if (input.stage === "done") {
8540
+ if (lastArchived === null) return {
8541
+ ...base,
8542
+ current: null,
8543
+ next: null,
8544
+ label: `当前第 ${input.activeChapter} 章 · 阶段 done(尚无归档章)`,
8545
+ resume: `引擎处于 done,但章节索引里还没有已归档章;先核对项目状态。`
8546
+ };
8547
+ const nextChapter = input.activeChapter > lastArchived ? input.activeChapter : lastArchived + 1;
8548
+ return {
8549
+ ...base,
8550
+ current: null,
8551
+ next: {
8552
+ chapter: nextChapter,
8553
+ stage: "P1"
8554
+ },
8555
+ label: `第 ${lastArchived} 章已归档 · 下一章第 ${nextChapter} 章待开始(P1)`,
8556
+ resume: `第 ${lastArchived} 章已归档;当前不处于写作阶段,下一章第 ${nextChapter} 章从 P1 开始。`
8557
+ };
8558
+ }
8559
+ return {
8560
+ ...base,
8561
+ current: {
8562
+ chapter: input.activeChapter,
8563
+ stage: input.stage
8564
+ },
8565
+ next: null,
8566
+ label: `第 ${input.activeChapter} 章 · 阶段 ${input.stage}`,
8567
+ resume: `当前第 ${input.activeChapter} 章,阶段 ${input.stage}。`
8568
+ };
8569
+ }
8366
8570
  /** One shared prompt template for `/novel resume` and the panel copy button. */
8367
8571
  function formatResumePrompt(input) {
8572
+ if (input.progress?.finished === true) return "";
8368
8573
  return [
8369
8574
  `请继续《${input.title}》。`,
8370
8575
  `项目根:${input.root}(第一次工具调用必须显式传 project_root=${input.root})。`,
8371
- `当前第 ${input.chapter} 章,阶段 ${input.stage}。`,
8576
+ input.progress?.resume ?? `当前第 ${input.chapter} 章,阶段 ${input.stage}。`,
8372
8577
  `下一步:${input.nextTool}。`,
8373
8578
  "先调用 novel_status 核对项目根与书名,再按 novel_pipeline 的下一步执行;",
8374
8579
  "只操作这个项目,不要读取或写入其他书。"
@@ -8434,8 +8639,11 @@ function projectSummary(snapshot) {
8434
8639
  platform: snapshot.platform,
8435
8640
  chapter: snapshot.chapter,
8436
8641
  stage: snapshot.stage,
8642
+ ...snapshot.progress === void 0 ? {} : { progress: snapshot.progress },
8437
8643
  revision: snapshot.revision,
8438
8644
  totalWords: snapshot.totalWords,
8645
+ ...snapshot.archivedWords === void 0 ? {} : { archivedWords: snapshot.archivedWords },
8646
+ ...snapshot.draftWords === void 0 ? {} : { draftWords: snapshot.draftWords },
8439
8647
  totalChapters: snapshot.chapters.length,
8440
8648
  averageScore: snapshot.averageScore ?? (snapshot.chapters.length === 0 ? 0 : snapshot.chapters.reduce((sum, chapter) => sum + chapter.score, 0) / snapshot.chapters.length),
8441
8649
  ...snapshot.volume === void 0 ? {} : { volume: snapshot.volume },
@@ -8886,6 +9094,10 @@ var EngineService = class {
8886
9094
  briefContextCache = /* @__PURE__ */ new WeakMap();
8887
9095
  /** The 15s panel poll reuses the heavy book projection until revision/damage changes. */
8888
9096
  panelCache = /* @__PURE__ */ new Map();
9097
+ /** Section-level cache; writes bump revision, so a stale entry cannot survive a commit. */
9098
+ sectionCache = /* @__PURE__ */ new Map();
9099
+ journalCache = /* @__PURE__ */ new WeakMap();
9100
+ reviewKeyCache = /* @__PURE__ */ new WeakMap();
8889
9101
  briefs;
8890
9102
  now;
8891
9103
  metadataMutex = new KeyedMutex();
@@ -10206,6 +10418,12 @@ var EngineService = class {
10206
10418
  */
10207
10419
  async statusFor(snapshot, chapter) {
10208
10420
  const metrics = await this.metricsFor(snapshot, chapter);
10421
+ const progress = projectChapterProgress({
10422
+ activeChapter: snapshot.pipeline.chapter,
10423
+ stage: snapshot.pipeline.stage,
10424
+ archived: snapshot.chapters.chapters.map((record) => record.num),
10425
+ target: snapshot.project.targets.total_chapters
10426
+ });
10209
10427
  const journal = await this.journalBlocking(snapshot);
10210
10428
  const pending = await inspectPending(this.storeFor(snapshot.root));
10211
10429
  const pendingWarnings = pending.state === "complete" || pending.state === "untouched" ? [{
@@ -10229,6 +10447,7 @@ var EngineService = class {
10229
10447
  active_chapter: snapshot.pipeline.chapter,
10230
10448
  manuscript_hash: snapshot.pipeline.draft !== void 0 && snapshot.text !== null ? sha256(snapshot.text) : null,
10231
10449
  stage: snapshot.pipeline.stage,
10450
+ progress,
10232
10451
  rounds: { ...snapshot.pipeline.rounds },
10233
10452
  blocking: [...this.effectiveBlocking(snapshot), ...journal],
10234
10453
  ...pendingWarnings.length === 0 ? {} : { warnings: pendingWarnings },
@@ -10410,7 +10629,7 @@ var EngineService = class {
10410
10629
  };
10411
10630
  }
10412
10631
  case "history": {
10413
- const entries = await listHistory(store, input.chapter);
10632
+ const entries = await listHistory(store, input.chapter, { withChars: true });
10414
10633
  if (input.revision !== void 0) {
10415
10634
  if (!Number.isInteger(input.revision) || input.revision < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "history revision must be a positive integer");
10416
10635
  const matching = entries.filter((entry) => entry.chapter === chapter && entry.revision === input.revision);
@@ -10540,6 +10759,12 @@ var EngineService = class {
10540
10759
  }
10541
10760
  async advise(input = {}) {
10542
10761
  const snapshot = await this.load(input.root);
10762
+ const progress = projectChapterProgress({
10763
+ activeChapter: snapshot.pipeline.chapter,
10764
+ stage: snapshot.pipeline.stage,
10765
+ archived: snapshot.chapters.chapters.map((record) => record.num),
10766
+ target: snapshot.project.targets.total_chapters
10767
+ });
10543
10768
  const journal = await this.journalBlocking(snapshot);
10544
10769
  const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
10545
10770
  tool: "novel_audit",
@@ -10550,6 +10775,7 @@ var EngineService = class {
10550
10775
  data: {
10551
10776
  stage: snapshot.pipeline.stage,
10552
10777
  chapter: snapshot.pipeline.chapter,
10778
+ progress,
10553
10779
  tool: next.tool,
10554
10780
  args: next.args,
10555
10781
  reason: next.reason,
@@ -10747,11 +10973,23 @@ var EngineService = class {
10747
10973
  detail: snapshot.projectError ?? `novel.yaml is readable`,
10748
10974
  ...snapshot.projectError === null ? {} : { hint: "read novel_state {kind:\"novel\"} and repair it with novel_amend" }
10749
10975
  });
10976
+ const profileCount = Object.values(this.rulepack.genres).filter((genre) => genre.profile !== void 0).length;
10750
10977
  add({
10751
- id: "pipeline",
10978
+ id: "rulepack",
10752
10979
  severity: "ok",
10980
+ title: "规则包",
10981
+ detail: `${this.rulepack.name}@${this.rulepack.version} · ${Object.keys(this.rulepack.genres).length} 个题材 · ${profileCount} 个题材 profile`
10982
+ });
10983
+ add({
10984
+ id: "pipeline",
10985
+ severity: snapshot.pipelineError === null ? "ok" : "error",
10753
10986
  title: "流水线",
10754
- detail: `revision ${snapshot.pipeline.revision} · 第 ${snapshot.pipeline.chapter} 章 · ${snapshot.pipeline.stage}`
10987
+ detail: `revision ${snapshot.pipeline.revision} · ${projectChapterProgress({
10988
+ activeChapter: snapshot.pipeline.chapter,
10989
+ stage: snapshot.pipeline.stage,
10990
+ archived: snapshot.chapters.chapters.map((record) => record.num),
10991
+ target: snapshot.project.targets.total_chapters
10992
+ }).label}`
10755
10993
  });
10756
10994
  for (const error of this.stateErrors(snapshot)) add({
10757
10995
  id: `state:${error.path}`,
@@ -11043,12 +11281,13 @@ var EngineService = class {
11043
11281
  foreshadows: snapshot.foreshadows,
11044
11282
  relationships: snapshot.relationships,
11045
11283
  reviews: currentReviews,
11046
- activity: journal.rows.slice(-8),
11284
+ activity: journal.rows.filter((row) => row.op !== "lock_reclaimed").slice(-8),
11047
11285
  journal: journal.rows,
11048
11286
  reviewAttempts,
11049
11287
  finalChecks,
11050
11288
  targetChapters: snapshot.project.targets.total_chapters,
11051
11289
  minScore: this.config.quality.minScore,
11290
+ dimensionMax: Object.fromEntries(this.rulepack.quality.dimensions.map((dimension) => [dimension.id, dimension.max])),
11052
11291
  foreshadowDormantAfter: this.rulepack.crosschapter.foreshadow_dormant_after
11053
11292
  });
11054
11293
  if (snapshot.projectError !== null) report.warnings.push(`项目定义不可读:${snapshot.projectError}`);
@@ -11124,7 +11363,13 @@ var EngineService = class {
11124
11363
  const snapshot = await this.loadAt(selection);
11125
11364
  await this.recordSelectionBestEffort(selection, snapshot);
11126
11365
  const target = snapshot.project.targets.total_chapters;
11127
- const finished = snapshot.pipeline.stage === "done" && target !== null && snapshot.chapters.chapters.length >= target;
11366
+ const progress = projectChapterProgress({
11367
+ activeChapter: snapshot.pipeline.chapter,
11368
+ stage: snapshot.pipeline.stage,
11369
+ archived: snapshot.chapters.chapters.map((record) => record.num),
11370
+ target
11371
+ });
11372
+ const finished = progress.finished;
11128
11373
  const journalBlocking = await this.journalBlocking(snapshot);
11129
11374
  const next = journalBlocking.length > 0 && this.stateErrors(snapshot).length === 0 ? {
11130
11375
  tool: "novel_audit",
@@ -11136,6 +11381,7 @@ var EngineService = class {
11136
11381
  projectPresent: true,
11137
11382
  chapter: snapshot.pipeline.chapter,
11138
11383
  stage: snapshot.pipeline.stage,
11384
+ progress,
11139
11385
  archived: snapshot.chapters.chapters.length,
11140
11386
  target,
11141
11387
  finished,
@@ -11535,6 +11781,7 @@ var EngineService = class {
11535
11781
  summary: input.summary,
11536
11782
  draftTitle: input.chapter_title,
11537
11783
  sceneType: input.scene_type,
11784
+ narrator: input.narrator,
11538
11785
  planTargetWords: snapshot.plan?.target_words ?? null,
11539
11786
  planCast: snapshot.plan?.cast === void 0 ? null : snapshot.plan.cast.map((member) => member.name)
11540
11787
  });
@@ -12119,6 +12366,10 @@ var EngineService = class {
12119
12366
  record.archived_at = archivedAt;
12120
12367
  const outline = markOutlineArchived(snapshot.outline, record, archivedAt, snapshot.project.targets.words_per_chapter);
12121
12368
  this.assertOutlineWritable(snapshot, outline, "novel_archive");
12369
+ const archivedCard = outline.chapters.find((card) => card.num === chapter);
12370
+ if (archivedCard === void 0) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `archive did not produce an outline card for chapter ${chapter}`, "restore the outline from backups/ and retry the archive");
12371
+ record.archive_card = archivedCardSnapshot(archivedCard);
12372
+ record.archive_card_digest = archivedCardDigest(record.archive_card);
12122
12373
  const foreshadows = ledger.foreshadows;
12123
12374
  const timeline = ledger.timeline;
12124
12375
  for (const item of foreshadows.items) if (item.status !== "resolved" && chapter - item.last_touched >= this.rulepack.crosschapter.foreshadow_dormant_after) item.status = "dormant";
@@ -12319,32 +12570,24 @@ var EngineService = class {
12319
12570
  oldDocument = narrow(kind, parse(backup));
12320
12571
  } catch {}
12321
12572
  }
12322
- if (kind === "outline" && (snapshot.outlineError !== null || !snapshot.outlinePresent)) {
12573
+ if (kind === "outline") {
12323
12574
  const old = recordOf(oldDocument);
12324
12575
  const oldChapters = Array.isArray(old?.chapters) ? [...old.chapters] : [];
12325
- const known = new Set(oldChapters.map((raw) => recordOf(raw)).filter((row) => row !== null).map((row) => row.num).filter((num) => Number.isInteger(num)));
12326
- const candidates = new Map((Array.isArray(document.chapters) ? document.chapters : []).map((raw) => recordOf(raw)).filter((row) => row !== null && Number.isInteger(row.num)).map((row) => [row.num, row]));
12576
+ const indexOf = /* @__PURE__ */ new Map();
12577
+ for (const [index, raw] of oldChapters.entries()) {
12578
+ const row = recordOf(raw);
12579
+ if (row !== null && Number.isInteger(row.num)) indexOf.set(row.num, index);
12580
+ }
12327
12581
  for (const record of snapshot.chapters.chapters) {
12328
- if (known.has(record.num)) continue;
12329
- const candidate = candidates.get(record.num);
12330
- if (candidate === void 0 || candidate.status !== "archived") continue;
12331
- let plan = null;
12332
- const planRaw = await context.store.readText(planChapterFile(record.num));
12333
- if (planRaw !== null) try {
12334
- const parsed = parsePlanYaml(planRaw, planChapterFile(record.num));
12335
- validateStoredChapterPlan(parsed, planChapterFile(record.num));
12336
- plan = parsed;
12337
- } catch {
12338
- plan = null;
12339
- }
12340
- const planCast = Array.isArray(plan?.cast) ? plan.cast.map((member) => recordOf(member)?.name ?? "").filter((name) => name !== "") : [];
12341
- const allowedTargets = [snapshot.project.targets.words_per_chapter, ...Array.isArray(plan?.target_words) ? [plan.target_words] : []];
12342
- const allowedCasts = [planCast, autoCardCast(record)];
12343
- const targetMatches = allowedTargets.some((target) => JSON.stringify(target) === JSON.stringify(candidate.target_words));
12344
- const castMatches = allowedCasts.some((cast) => JSON.stringify(cast) === JSON.stringify(candidate.cast));
12345
- if (!targetMatches || !castMatches) continue;
12346
- oldChapters.push({ ...candidate });
12347
- known.add(record.num);
12582
+ if (record.archive_card === void 0) continue;
12583
+ if (record.archive_card_digest === void 0 || record.archive_card_digest !== archivedCardDigest(record.archive_card)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `archived chapter ${record.num} has an archive_card whose digest does not match`, "restore state/chapters.yaml from backups/ or repair the card evidence manually");
12584
+ const archiveCardRow = archivedCardRecord(record);
12585
+ const index = indexOf.get(record.num);
12586
+ if (index === void 0) oldChapters.push(archiveCardRow);
12587
+ else oldChapters[index] = {
12588
+ ...recordOf(oldChapters[index]) ?? {},
12589
+ ...archiveCardRow
12590
+ };
12348
12591
  }
12349
12592
  oldDocument = {
12350
12593
  ...old ?? {},
@@ -12744,6 +12987,436 @@ var EngineService = class {
12744
12987
  return projectGates(await this.panelSnapshot(input.root));
12745
12988
  }
12746
12989
  /**
12990
+ * Additive v7 section read model. Catalog sections reuse the Snapshot; only
12991
+ * the quality section reads archived reviews, and only chapter/quality
12992
+ * detail reads manuscripts or check files. Every section is byte-capped.
12993
+ */
12994
+ async panelSection(input = { section: "overview" }) {
12995
+ const selection = await this.resolveRoot(input.root);
12996
+ if (input.root !== void 0 && selection.root !== null) {
12997
+ const selected = resolve(selection.root);
12998
+ const registry = await this.registry().read();
12999
+ const configured = this.defaultRoot();
13000
+ if (!(registry.active !== null && resolve(registry.active) === selected ? true : registry.projects.some((entry) => resolve(entry.root) === selected) || selected === resolve(configured))) throw new EngineInputError(ERROR_CODES.PROJECT_NOT_FOUND, `panel section root is not registered: ${selected}`, "用 /novel scan --register 或 /novel admin novel_registry_register 登记后刷新;面板不读取任意路径");
13001
+ }
13002
+ const snapshot = await this.loadAt(selection);
13003
+ const section = input.section;
13004
+ const query = input.query ?? {};
13005
+ const cacheable = section !== "chapter_detail" && section !== "quality_detail";
13006
+ let sectionKey = null;
13007
+ if (cacheable) {
13008
+ const journalIdentity = await this.panelJournalIdentity(snapshot);
13009
+ const reviewKey = section === "quality" ? await this.panelReviewKey(snapshot) : "";
13010
+ sectionKey = sha256(stableJson({
13011
+ root: snapshot.root,
13012
+ revision: snapshot.pipeline.revision,
13013
+ journalKey: journalIdentity.key,
13014
+ reviewKey,
13015
+ section,
13016
+ query,
13017
+ stage: snapshot.pipeline.stage,
13018
+ errors: [
13019
+ snapshot.projectError,
13020
+ snapshot.pipelineError,
13021
+ snapshot.planError,
13022
+ snapshot.reviewError,
13023
+ snapshot.checkError,
13024
+ snapshot.outlineError,
13025
+ snapshot.worldbookError,
13026
+ ...snapshot.ledgerErrors.map((error) => error.code)
13027
+ ]
13028
+ }));
13029
+ const cachedSection = this.sectionCache.get(sectionKey);
13030
+ if (cachedSection !== void 0) return cachedSection.value;
13031
+ }
13032
+ let items = null;
13033
+ let total = 0;
13034
+ let truncated = false;
13035
+ let nextCursor = null;
13036
+ if (section === "overview") {
13037
+ const panel = await this.panelSnapshot(void 0, {
13038
+ snapshot,
13039
+ includeQuality: false
13040
+ });
13041
+ items = {
13042
+ ...panel,
13043
+ chapters: panel.chapters.slice(-5),
13044
+ bookData: panel.bookData === void 0 ? void 0 : {
13045
+ ...panel.bookData,
13046
+ quality: {
13047
+ ...panel.bookData.quality,
13048
+ dimensions: [],
13049
+ samples: 0,
13050
+ attempts: 0
13051
+ },
13052
+ effort: {
13053
+ ...panel.bookData.effort,
13054
+ chapters: []
13055
+ }
13056
+ }
13057
+ };
13058
+ total = 1;
13059
+ } else if (section === "chapter_detail" || section === "quality_detail") {
13060
+ const chapter = query.chapter ?? snapshot.pipeline.chapter;
13061
+ if (!Number.isInteger(chapter) || chapter < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapter detail requires a positive chapter");
13062
+ const row = snapshot.chapters.chapters.find((record) => record.num === chapter) ?? null;
13063
+ const active = chapter === snapshot.pipeline.chapter;
13064
+ const store = this.storeFor(snapshot.root);
13065
+ if (section === "quality_detail") {
13066
+ items = {
13067
+ chapter,
13068
+ review: await store.readText(reviewFile(chapter)),
13069
+ check: await store.readText(checkFile(chapter))
13070
+ };
13071
+ total = row === null && !active ? 0 : 1;
13072
+ } else {
13073
+ items = {
13074
+ chapter,
13075
+ row,
13076
+ manuscript: row === null || row.file === "" ? active ? snapshot.text : null : await store.readText(row.file),
13077
+ plan: await store.readText(planChapterFile(chapter)),
13078
+ world_plan: await store.readText(planWorldFile(chapter)),
13079
+ summary_events: (row?.summary.events ?? snapshot.pipeline.draft?.summary?.events ?? []).slice(0, 4)
13080
+ };
13081
+ total = row === null && !active ? 0 : 1;
13082
+ }
13083
+ } else if (section === "chapters") {
13084
+ const archivedCurrent = snapshot.chapters.chapters.some((record) => record.num === snapshot.pipeline.chapter);
13085
+ const draftWords = snapshot.text === null || archivedCurrent || snapshot.pipeline.stage === "done" ? 0 : countWordMetrics(snapshot.text).total_word_count;
13086
+ const rows = snapshot.chapters.chapters.map((record) => ({
13087
+ num: record.num,
13088
+ title: record.title,
13089
+ file: record.file,
13090
+ word_count: record.word_count,
13091
+ score: record.score,
13092
+ status: "archived",
13093
+ scene_type: record.scene_type,
13094
+ dialogue_ratio: record.dialogue_ratio,
13095
+ final_check: record.final_check ?? null,
13096
+ archived_at: record.archived_at ?? null,
13097
+ summary_events: record.summary.events.slice(0, 4),
13098
+ rounds: record.rounds ?? null
13099
+ }));
13100
+ if (!archivedCurrent && snapshot.pipeline.stage !== "done") rows.push({
13101
+ num: snapshot.pipeline.chapter,
13102
+ title: stripTitlePrefix(snapshot.plan?.title ?? "") || `第${snapshot.pipeline.chapter}章`,
13103
+ file: null,
13104
+ word_count: draftWords,
13105
+ score: snapshot.review?.total ?? null,
13106
+ status: "draft",
13107
+ scene_type: snapshot.plan?.scene_type ?? null,
13108
+ dialogue_ratio: null,
13109
+ final_check: null,
13110
+ archived_at: null,
13111
+ summary_events: (snapshot.pipeline.draft?.summary?.events ?? []).slice(0, 4),
13112
+ rounds: snapshot.pipeline.rounds
13113
+ });
13114
+ const sorted = rows.filter((row) => {
13115
+ if (query.status !== void 0 && query.status !== "" && row.status !== query.status) return false;
13116
+ if (query.scene !== void 0 && query.scene !== "" && row.scene_type !== query.scene) return false;
13117
+ if (query.search !== void 0 && query.search !== "") {
13118
+ if (!String(row.title ?? "").includes(query.search)) return false;
13119
+ }
13120
+ return true;
13121
+ }).sort((left, right) => {
13122
+ const byNum = Number(left.num) - Number(right.num);
13123
+ return query.sort === "num_asc" ? byNum : -byNum;
13124
+ });
13125
+ total = sorted.length;
13126
+ const page = this.panelSectionPage(sorted, query);
13127
+ items = page.items;
13128
+ truncated = page.truncated;
13129
+ nextCursor = page.nextCursor;
13130
+ } else if (section === "characters") {
13131
+ const seen = /* @__PURE__ */ new Map();
13132
+ for (const record of snapshot.chapters.chapters) for (const name of record.summary.characters_appeared) {
13133
+ const current = seen.get(name) ?? {
13134
+ first: record.num,
13135
+ last: record.num,
13136
+ appearances: 0
13137
+ };
13138
+ current.first = Math.min(current.first, record.num);
13139
+ current.last = Math.max(current.last, record.num);
13140
+ current.appearances += 1;
13141
+ seen.set(name, current);
13142
+ }
13143
+ const filtered = snapshot.characters.characters.map((character) => ({
13144
+ ...character,
13145
+ ...seen.get(character.name) ?? {
13146
+ first: character.first_appearance,
13147
+ last: null,
13148
+ appearances: 0
13149
+ }
13150
+ })).filter((row) => query.search === void 0 || query.search === "" ? true : row.name.includes(query.search) || row.role.includes(query.search));
13151
+ total = filtered.length;
13152
+ const page = this.panelSectionPage(filtered, query, 200);
13153
+ items = page.items;
13154
+ truncated = page.truncated;
13155
+ nextCursor = page.nextCursor;
13156
+ } else if (section === "foreshadows") {
13157
+ const filtered = snapshot.foreshadows.items.filter((row) => {
13158
+ if (query.status !== void 0 && query.status !== "" && row.status !== query.status) return false;
13159
+ return query.search === void 0 || query.search === "" ? true : row.title.includes(query.search);
13160
+ });
13161
+ const page = this.panelSectionPage(filtered, query, 200);
13162
+ total = filtered.length + snapshot.timeline.items.length + snapshot.relationships.items.length;
13163
+ truncated = page.truncated || snapshot.timeline.items.length > 200 || snapshot.relationships.items.length > 200;
13164
+ items = {
13165
+ foreshadows: page.items,
13166
+ timeline: snapshot.timeline.items.slice(0, 200),
13167
+ relationships: snapshot.relationships.items.slice(0, 200)
13168
+ };
13169
+ nextCursor = page.nextCursor;
13170
+ } else if (section === "timeline") {
13171
+ const filtered = snapshot.timeline.items.filter((row) => query.search === void 0 || query.search === "" ? true : row.description.includes(query.search) || (row.location ?? "").includes(query.search));
13172
+ total = filtered.length;
13173
+ const page = this.panelSectionPage(filtered, query, 200);
13174
+ items = page.items;
13175
+ truncated = page.truncated;
13176
+ nextCursor = page.nextCursor;
13177
+ } else if (section === "relationships") {
13178
+ const filtered = snapshot.relationships.items.filter((row) => query.search === void 0 || query.search === "" ? true : row.a.includes(query.search) || row.b.includes(query.search) || row.type.includes(query.search));
13179
+ total = filtered.length;
13180
+ const page = this.panelSectionPage(filtered, query, 200);
13181
+ items = page.items;
13182
+ truncated = page.truncated;
13183
+ nextCursor = page.nextCursor;
13184
+ } else if (section === "worldbook") {
13185
+ const world = snapshot.world;
13186
+ const book = snapshot.worldbook;
13187
+ const search = query.search?.trim() ?? "";
13188
+ const matches = (value) => {
13189
+ if (search === "") return true;
13190
+ if (typeof value === "string") return value.includes(search);
13191
+ if (typeof value === "number" || typeof value === "boolean") return String(value).includes(search);
13192
+ if (Array.isArray(value)) return value.some((item) => matches(item));
13193
+ if (typeof value === "object" && value !== null) return Object.values(value).some((item) => matches(item));
13194
+ return false;
13195
+ };
13196
+ const filterRows = (rows) => rows.filter((row) => matches(row));
13197
+ const capRows = (rows) => rows.slice(0, 200);
13198
+ const groups = {
13199
+ rules: filterRows(book.rules),
13200
+ factions: filterRows(book.factions),
13201
+ locations: filterRows(book.locations),
13202
+ characters: filterRows(book.characters),
13203
+ glossary: filterRows(book.glossary),
13204
+ backstory: filterRows(book.backstory),
13205
+ facts: filterRows(book.facts ?? []),
13206
+ themes: filterRows(book.themes)
13207
+ };
13208
+ const ledger = {
13209
+ locations: filterRows(world.locations),
13210
+ abilities: filterRows(world.abilities),
13211
+ artifacts: filterRows(world.artifacts),
13212
+ factions: filterRows(world.factions),
13213
+ rules: filterRows(world.rules)
13214
+ };
13215
+ const outlineCards = filterRows(snapshot.outline.chapters);
13216
+ const all = [
13217
+ ...Object.values(groups),
13218
+ ...Object.values(ledger),
13219
+ outlineCards
13220
+ ];
13221
+ total = all.reduce((count, rows) => count + rows.length, 0);
13222
+ truncated = all.some((rows) => rows.length > 200);
13223
+ items = {
13224
+ premise: book.premise,
13225
+ setting: book.setting,
13226
+ blueprint: snapshot.outline.book,
13227
+ volumes: snapshot.outline.volumes,
13228
+ rules: capRows(groups.rules),
13229
+ factions: capRows(groups.factions),
13230
+ locations: capRows(groups.locations),
13231
+ characters: capRows(groups.characters),
13232
+ glossary: capRows(groups.glossary),
13233
+ backstory: capRows(groups.backstory),
13234
+ facts: capRows(groups.facts),
13235
+ themes: capRows(groups.themes),
13236
+ ledger: {
13237
+ locations: capRows(ledger.locations),
13238
+ abilities: capRows(ledger.abilities),
13239
+ artifacts: capRows(ledger.artifacts),
13240
+ factions: capRows(ledger.factions),
13241
+ rules: capRows(ledger.rules)
13242
+ },
13243
+ outline_cards: capRows(outlineCards),
13244
+ drift: outlineDrift(snapshot.outline, snapshot.chapters.chapters)
13245
+ };
13246
+ } else if (section === "quality") {
13247
+ const panel = await this.panelSnapshot(void 0, {
13248
+ snapshot,
13249
+ includeQuality: true
13250
+ });
13251
+ const maxById = new Map(this.rulepack.quality.dimensions.map((dimension) => [dimension.id, dimension.max]));
13252
+ items = {
13253
+ quality: panel.bookData?.quality ?? null,
13254
+ dimensions: (panel.bookData?.quality.dimensions ?? []).map((dimension) => ({
13255
+ ...dimension,
13256
+ max: maxById.get(dimension.id) ?? null
13257
+ })),
13258
+ pacing: panel.bookData?.pacing ?? null,
13259
+ effort: panel.bookData?.effort ?? null
13260
+ };
13261
+ total = items === null ? 0 : 1;
13262
+ } else {
13263
+ const panel = await this.panelSnapshot(void 0, {
13264
+ snapshot,
13265
+ includeQuality: false
13266
+ });
13267
+ items = {
13268
+ health: panel.health ?? null,
13269
+ blocking: panel.blocking,
13270
+ state_errors: this.stateErrors(snapshot),
13271
+ outline: {
13272
+ present: snapshot.outlinePresent && snapshot.outlineError === null,
13273
+ file_existed: snapshot.outlinePresent,
13274
+ error: snapshot.outlineError
13275
+ },
13276
+ worldbook: {
13277
+ present: snapshot.worldbookPresent && snapshot.worldbookError === null,
13278
+ file_existed: snapshot.worldbookPresent,
13279
+ error: snapshot.worldbookError
13280
+ },
13281
+ drift: outlineDrift(snapshot.outline, snapshot.chapters.chapters),
13282
+ pending: panel.pending ?? null
13283
+ };
13284
+ total = 1;
13285
+ }
13286
+ const clipped = section === "chapter_detail" || section === "quality_detail" ? {
13287
+ items,
13288
+ truncated: false
13289
+ } : this.panelSectionClip(items, section);
13290
+ items = clipped.items;
13291
+ truncated = truncated || clipped.truncated;
13292
+ const budgeted = this.panelSectionBudget(items, 65536);
13293
+ items = budgeted.items;
13294
+ truncated = truncated || budgeted.truncated;
13295
+ if (budgeted.truncated) nextCursor = null;
13296
+ const updatedAt = this.now().toISOString();
13297
+ const etag = sha256(stableJson({
13298
+ root: snapshot.root,
13299
+ revision: snapshot.pipeline.revision,
13300
+ section,
13301
+ query,
13302
+ items,
13303
+ total,
13304
+ truncated
13305
+ }));
13306
+ const result = {
13307
+ payload_version: 7,
13308
+ section,
13309
+ items,
13310
+ total,
13311
+ returned: this.panelSectionReturned(items),
13312
+ truncated,
13313
+ next_cursor: nextCursor,
13314
+ updated_at: updatedAt,
13315
+ etag
13316
+ };
13317
+ if (sectionKey !== null) {
13318
+ this.sectionCache.set(sectionKey, {
13319
+ key: sectionKey,
13320
+ value: result
13321
+ });
13322
+ if (this.sectionCache.size > 64) {
13323
+ const oldest = this.sectionCache.keys().next().value;
13324
+ if (oldest !== void 0) this.sectionCache.delete(oldest);
13325
+ }
13326
+ }
13327
+ return result;
13328
+ }
13329
+ panelSectionPage(rows, query, defaultLimit = 50) {
13330
+ const requested = query.limit ?? defaultLimit;
13331
+ const limit = Number.isInteger(requested) ? Math.min(200, Math.max(1, requested)) : defaultLimit;
13332
+ const cursor = Number.parseInt(query.cursor ?? "0", 10);
13333
+ const start = Number.isInteger(cursor) && cursor >= 0 ? cursor : 0;
13334
+ const items = rows.slice(start, start + limit);
13335
+ const truncated = start + items.length < rows.length;
13336
+ return {
13337
+ items,
13338
+ truncated,
13339
+ nextCursor: truncated ? String(start + items.length) : null
13340
+ };
13341
+ }
13342
+ panelSectionReturned(items) {
13343
+ if (Array.isArray(items)) return items.length;
13344
+ if (typeof items === "object" && items !== null) return Object.values(items).reduce((total, value) => total + (Array.isArray(value) ? value.length : 1), 0);
13345
+ return items === null ? 0 : 1;
13346
+ }
13347
+ /** WP3.7 free-text caps before the byte budget; both report truncation. */
13348
+ panelSectionClip(value, section) {
13349
+ let truncated = false;
13350
+ const walk = (input, path) => {
13351
+ if (typeof input === "string") {
13352
+ const keys = path.filter((part) => !/^\d+$/.test(part));
13353
+ const key = keys[keys.length - 1] ?? "";
13354
+ const worldbookText = section === "worldbook" || keys.some((part) => part === "ledger" || part === "worldbook");
13355
+ const max = key === "description" && worldbookText ? 240 : 400;
13356
+ const chars = Array.from(input);
13357
+ if (chars.length > max) {
13358
+ truncated = true;
13359
+ return chars.slice(0, max).join("");
13360
+ }
13361
+ return input;
13362
+ }
13363
+ if (Array.isArray(input)) return input.map((item, index) => walk(item, [...path, String(index)]));
13364
+ if (typeof input === "object" && input !== null) return Object.fromEntries(Object.entries(input).map(([key, item]) => [key, walk(item, [...path, key])]));
13365
+ return input;
13366
+ };
13367
+ return {
13368
+ items: walk(value, []),
13369
+ truncated
13370
+ };
13371
+ }
13372
+ panelSectionBudget(value, limit) {
13373
+ const bytes = (input) => Buffer.byteLength(JSON.stringify(input) ?? "null");
13374
+ if (bytes(value) <= limit) return {
13375
+ items: value,
13376
+ truncated: false
13377
+ };
13378
+ const clipStrings = (input, maxChars) => {
13379
+ if (typeof input === "string") {
13380
+ const chars = Array.from(input);
13381
+ return chars.length <= maxChars ? input : chars.slice(0, maxChars).join("");
13382
+ }
13383
+ if (Array.isArray(input)) return input.map((item) => clipStrings(item, maxChars));
13384
+ if (typeof input === "object" && input !== null) return Object.fromEntries(Object.entries(input).map(([key, item]) => [key, clipStrings(item, maxChars)]));
13385
+ return input;
13386
+ };
13387
+ const popOne = (input) => {
13388
+ if (Array.isArray(input)) {
13389
+ if (input.length > 1) {
13390
+ input.pop();
13391
+ return true;
13392
+ }
13393
+ return input.some((item) => popOne(item));
13394
+ }
13395
+ if (typeof input === "object" && input !== null) return Object.values(input).some((item) => popOne(item));
13396
+ return false;
13397
+ };
13398
+ for (const maxChars of [
13399
+ 2e3,
13400
+ 1e3,
13401
+ 500,
13402
+ 200,
13403
+ 80,
13404
+ 20,
13405
+ 0
13406
+ ]) {
13407
+ const candidate = clipStrings(value, maxChars);
13408
+ while (bytes(candidate) > limit) if (!popOne(candidate)) break;
13409
+ if (bytes(candidate) <= limit) return {
13410
+ items: candidate,
13411
+ truncated: true
13412
+ };
13413
+ }
13414
+ return {
13415
+ items: Array.isArray(value) ? [] : typeof value === "object" && value !== null ? {} : typeof value === "string" ? "" : null,
13416
+ truncated: true
13417
+ };
13418
+ }
13419
+ /**
12747
13420
  * Build the read-only panel payload. The query root is accepted only when it
12748
13421
  * is registered, active or the configured root; arbitrary paths never reach
12749
13422
  * the filesystem from the panel route.
@@ -12776,9 +13449,10 @@ var EngineService = class {
12776
13449
  add(await optionFor(active, known !== void 0, known?.title, known?.created_at));
12777
13450
  }
12778
13451
  const configured = this.defaultRoot();
13452
+ const explicitConfigured = this.config.projectRoot !== "./novel" && configured !== resolve("./novel");
12779
13453
  if (!rows.has(configured)) {
12780
13454
  const option = await optionFor(configured, false);
12781
- if (option.state !== "missing" || active === configured) add(option);
13455
+ if (option.state !== "missing" || active === configured || explicitConfigured) add(option);
12782
13456
  }
12783
13457
  const selectedRoot = input.root === void 0 ? void 0 : resolve(input.root);
12784
13458
  if (selectedRoot !== void 0 && !rows.has(selectedRoot) && selectedRoot === configured) add(await optionFor(configured, false));
@@ -12842,7 +13516,7 @@ var EngineService = class {
12842
13516
  };
12843
13517
  let snapshot;
12844
13518
  try {
12845
- snapshot = await this.panelSnapshot(selection.root);
13519
+ snapshot = await this.panelSnapshot(selection.root, { includeQuality: input.lean !== true });
12846
13520
  } catch (error) {
12847
13521
  if (error instanceof EngineError) {
12848
13522
  const busy = error.code === ERROR_CODES.TXN_IN_PROGRESS;
@@ -12863,19 +13537,29 @@ var EngineService = class {
12863
13537
  }
12864
13538
  const summary = projectSummary(snapshot);
12865
13539
  if (option.state !== "ok" && option.title !== "") summary.title = option.title;
12866
- const resume = summary.next === void 0 ? void 0 : { text: formatResumePrompt({
13540
+ const resume = summary.next === void 0 || summary.progress?.finished === true ? void 0 : { text: formatResumePrompt({
12867
13541
  title: summary.title,
12868
13542
  root: summary.root,
12869
13543
  chapter: summary.chapter,
12870
13544
  stage: summary.stage,
12871
- nextTool: summary.next.tool
13545
+ nextTool: summary.next.tool,
13546
+ ...summary.progress === void 0 ? {} : { progress: summary.progress }
12872
13547
  }) };
13548
+ const chapters = projectChapters(snapshot);
13549
+ const etag = sha256(stableJson({
13550
+ root: snapshot.root,
13551
+ projects,
13552
+ selection,
13553
+ summary,
13554
+ chapters,
13555
+ ...resume === void 0 ? {} : { resume }
13556
+ }));
12873
13557
  return {
12874
13558
  projects,
12875
13559
  selection,
12876
13560
  summary,
12877
- chapters: projectChapters(snapshot),
12878
- gates: projectGates(snapshot),
13561
+ chapters,
13562
+ etag,
12879
13563
  ...resume === void 0 ? {} : { resume }
12880
13564
  };
12881
13565
  }
@@ -12888,12 +13572,13 @@ var EngineService = class {
12888
13572
  const nextTool = status.data.next?.tool;
12889
13573
  return {
12890
13574
  data: {
12891
- text: nextTool === void 0 ? null : formatResumePrompt({
13575
+ text: nextTool === void 0 || status.data.progress?.finished === true ? null : formatResumePrompt({
12892
13576
  title: status.data.project.title,
12893
13577
  root: status.data.project.root,
12894
13578
  chapter: status.data.chapter,
12895
13579
  stage: status.data.stage,
12896
- nextTool
13580
+ nextTool,
13581
+ ...status.data.progress === void 0 ? {} : { progress: status.data.progress }
12897
13582
  }),
12898
13583
  selection: status.data.selection
12899
13584
  },
@@ -12903,12 +13588,18 @@ var EngineService = class {
12903
13588
  }
12904
13589
  async stats(input = {}) {
12905
13590
  const snapshot = await this.load(input.root);
12906
- const totalWords = snapshot.chapters.chapters.reduce((total, chapter) => total + chapter.word_count, 0);
13591
+ const stateErrors = this.stateErrors(snapshot);
13592
+ const archivedWords = snapshot.chapters.chapters.reduce((total, chapter) => total + chapter.word_count, 0);
13593
+ const activeIndexed = snapshot.chapters.chapters.some((chapter) => chapter.num === snapshot.pipeline.chapter);
13594
+ const draftWords = snapshot.text === null || activeIndexed || snapshot.pipeline.stage === "done" ? 0 : countWordMetrics(snapshot.text).total_word_count;
13595
+ const totalWords = archivedWords + draftWords;
12907
13596
  const scores = snapshot.chapters.chapters.map((chapter) => chapter.score);
12908
13597
  const outline = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length, snapshot.project.targets.total_chapters, snapshot.chapters.chapters.map((record) => record.num));
12909
13598
  const counts = worldbookCounts(snapshot.worldbook);
12910
13599
  return {
12911
13600
  totalWords,
13601
+ archivedWords,
13602
+ draftWords,
12912
13603
  chapters: snapshot.chapters.chapters.length,
12913
13604
  revision: snapshot.pipeline.revision,
12914
13605
  rulepack: `${this.rulepack.name}@${this.rulepack.version}`,
@@ -12923,7 +13614,9 @@ var EngineService = class {
12923
13614
  blueprintReady: bookPlanPresent(snapshot.outline.book),
12924
13615
  worldbookEntries: counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory + counts.facts,
12925
13616
  strictOutline: snapshot.outline.book.strict,
12926
- targetChapters: snapshot.project.targets.total_chapters
13617
+ targetChapters: snapshot.project.targets.total_chapters,
13618
+ state_errors: stateErrors,
13619
+ partial: stateErrors.length > 0
12927
13620
  };
12928
13621
  }
12929
13622
  registry() {
@@ -12942,6 +13635,17 @@ var EngineService = class {
12942
13635
  ...inspection.state === "ok" && !sameIdentity ? { identity_changed: true } : {}
12943
13636
  };
12944
13637
  }));
13638
+ const configured = this.defaultRoot();
13639
+ if (configured !== "" && !document.projects.some((entry) => resolve(entry.root) === resolve(configured))) {
13640
+ const inspection = await inspectProject(configured);
13641
+ if (inspection.state === "missing") projects.push({
13642
+ root: configured,
13643
+ title: "配置项目根",
13644
+ created_at: "",
13645
+ updated_at: "",
13646
+ state: inspection.state
13647
+ });
13648
+ }
12945
13649
  return {
12946
13650
  active: document.active,
12947
13651
  projects
@@ -13400,21 +14104,70 @@ var EngineService = class {
13400
14104
  }
13401
14105
  };
13402
14106
  }
13403
- async panelSnapshot(root) {
13404
- const selection = await this.resolveRoot(root);
13405
- const snapshot = await this.loadAt(selection);
14107
+ panelReviewKey(snapshot) {
14108
+ const cached = this.reviewKeyCache.get(snapshot);
14109
+ if (cached !== void 0) return cached;
14110
+ const pending = (async () => {
14111
+ const store = this.storeFor(snapshot.root);
14112
+ const names = await store.list("reviews");
14113
+ const files = await Promise.all(names.map(async (name) => {
14114
+ const stat = await store.stat(`reviews/${name}`);
14115
+ return [name, stat === null ? null : [stat.size, Math.round(stat.mtimeMs)]];
14116
+ }));
14117
+ return sha256(stableJson(files));
14118
+ })();
14119
+ this.reviewKeyCache.set(snapshot, pending);
14120
+ return pending;
14121
+ }
14122
+ panelJournalIdentity(snapshot) {
14123
+ const cached = this.journalCache.get(snapshot);
14124
+ if (cached !== void 0) return cached;
14125
+ const pending = (async () => {
14126
+ const journal = await readJournal(this.storeFor(snapshot.root));
14127
+ const journalBlocking = await this.journalBlocking(snapshot);
14128
+ const tail = journal.rows[journal.rows.length - 1];
14129
+ return {
14130
+ key: sha256(stableJson({
14131
+ rows: journal.rows.length,
14132
+ tail: tail === void 0 ? null : [
14133
+ tail.seq,
14134
+ tail.at,
14135
+ tail.status
14136
+ ],
14137
+ blocking: journalBlocking
14138
+ })),
14139
+ journal,
14140
+ blocking: journalBlocking
14141
+ };
14142
+ })();
14143
+ this.journalCache.set(snapshot, pending);
14144
+ return pending;
14145
+ }
14146
+ async panelSnapshot(root, options = {}) {
14147
+ const snapshot = options.snapshot ?? await this.loadAt(await this.resolveRoot(root));
14148
+ const includeQuality = options.includeQuality !== false;
13406
14149
  const archived = snapshot.chapters.chapters;
13407
14150
  const archivedCurrent = archived.some((chapter) => chapter.num === snapshot.pipeline.chapter);
13408
14151
  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;
14152
+ const progress = projectChapterProgress({
14153
+ activeChapter: snapshot.pipeline.chapter,
14154
+ stage: snapshot.pipeline.stage,
14155
+ archived: archived.map((record) => record.num),
14156
+ target
14157
+ });
14158
+ const finished = progress.finished;
14159
+ const draftWords = snapshot.text === null || archivedCurrent || snapshot.pipeline.stage === "done" ? 0 : countWordMetrics(snapshot.text).total_word_count;
14160
+ const archivedWords = archived.reduce((total, chapter) => total + chapter.word_count, 0);
13411
14161
  const pending = await inspectPending(this.storeFor(snapshot.root));
13412
14162
  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)}`;
14163
+ const journalIdentity = await this.panelJournalIdentity(snapshot);
14164
+ const journalKey = journalIdentity.key;
14165
+ const journal = journalIdentity.journal;
14166
+ const journalBlocking = journalIdentity.blocking;
14167
+ const reviewKey = includeQuality ? await this.panelReviewKey(snapshot) : "";
14168
+ const panelCacheKey = `${snapshot.root}:${snapshot.pipeline.revision}:${pending.state}:${stateErrorCount}:${snapshot.outlineError ?? ""}:${snapshot.worldbookError ?? ""}:${JSON.stringify(snapshot.briefs)}:${journalKey}:${reviewKey}:${includeQuality ? "full" : "base"}`;
13414
14169
  const cachedPanel = this.panelCache.get(snapshot.root);
13415
14170
  if (cachedPanel?.key === panelCacheKey) return cachedPanel.snapshot;
13416
- const journal = await readJournal(this.storeFor(snapshot.root));
13417
- const journalBlocking = await this.journalBlocking(snapshot);
13418
14171
  const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
13419
14172
  const counts = worldbookCounts(snapshot.worldbook);
13420
14173
  const scores = archived.slice(-10).map((chapter) => chapter.score);
@@ -13461,26 +14214,31 @@ var EngineService = class {
13461
14214
  const qualityScores = scores.slice(-5);
13462
14215
  const previousScores = scores.slice(-10, -5);
13463
14216
  const store = this.storeFor(snapshot.root);
13464
- const reviewNames = new Set(await store.list(REVIEW_DIR));
13465
- const sampleChapters = /* @__PURE__ */ new Set();
13466
14217
  const currentReviews = [];
13467
- for (const record of archived) {
13468
- const name = reviewFile(record.num).slice(`${REVIEW_DIR}/`.length);
13469
- if (!reviewNames.has(name)) continue;
13470
- try {
13471
- const review = await store.readDoc(reviewFile(record.num), "review");
13472
- if (review !== null) {
13473
- sampleChapters.add(record.num);
13474
- currentReviews.push(review);
13475
- }
13476
- } catch {}
14218
+ let samples = 0;
14219
+ let attempts = 0;
14220
+ if (includeQuality) {
14221
+ const reviewNames = new Set(await store.list(REVIEW_DIR));
14222
+ const sampleChapters = /* @__PURE__ */ new Set();
14223
+ for (const record of archived) {
14224
+ const name = reviewFile(record.num).slice(`${REVIEW_DIR}/`.length);
14225
+ if (!reviewNames.has(name)) continue;
14226
+ try {
14227
+ const review = await store.readDoc(reviewFile(record.num), "review");
14228
+ if (review !== null) {
14229
+ sampleChapters.add(record.num);
14230
+ currentReviews.push(review);
14231
+ }
14232
+ } catch {}
14233
+ }
14234
+ samples = sampleChapters.size;
14235
+ const archivedChapterNums = new Set(archived.map((record) => record.num));
14236
+ attempts = [...(await this.reviewAttemptsByChapter(store, journal.rows, [...archivedChapterNums], sampleChapters)).values()].reduce((total, count) => total + count, 0);
13477
14237
  }
13478
- const samples = sampleChapters.size;
13479
- const archivedChapterNums = new Set(archived.map((record) => record.num));
13480
- const attempts = [...(await this.reviewAttemptsByChapter(store, journal.rows, [...archivedChapterNums], sampleChapters)).values()].reduce((total, count) => total + count, 0);
13481
14238
  const qualityTrendBasis = samples < 3 ? "insufficient_samples" : previousScores.length < 5 ? "insufficient_history" : "last_five_vs_previous_five";
13482
14239
  const quality = {
13483
- averageScore: scores.length === 0 ? 0 : scores.reduce((total, score) => total + score, 0) / scores.length,
14240
+ averageScore: archived.length === 0 ? 0 : archived.reduce((total, chapter) => total + chapter.score, 0) / archived.length,
14241
+ last_ten_average: scores.length === 0 ? null : scores.reduce((total, score) => total + score, 0) / scores.length,
13484
14242
  scores,
13485
14243
  trend: qualityTrendBasis === "last_five_vs_previous_five" ? Math.round((qualityScores.reduce((total, score) => total + score, 0) / qualityScores.length - previousScores.reduce((total, score) => total + score, 0) / previousScores.length) * 10) / 10 : null,
13486
14244
  trend_basis: qualityTrendBasis,
@@ -13497,37 +14255,58 @@ var EngineService = class {
13497
14255
  hint: `第 ${violation.location.paragraph} 段`
13498
14256
  }));
13499
14257
  const legacy = await this.legacyIssues(snapshot, false);
13500
- const activity = journal.rows.slice(-5).map((row) => ({
14258
+ const activity = journal.rows.filter((row) => row.op !== "lock_reclaimed").slice(-5).map((row) => ({
13501
14259
  at: row.at,
13502
14260
  op: row.op,
13503
14261
  chapter: row.chapter,
13504
14262
  status: row.status
13505
14263
  }));
13506
14264
  const draftMetrics = snapshot.text === null ? null : countWordMetrics(snapshot.text);
14265
+ const panelReport = buildBookReport({
14266
+ project: snapshot.project,
14267
+ outline: snapshot.outline,
14268
+ outlinePresent: snapshot.outlinePresent,
14269
+ outlineError: snapshot.outlineError,
14270
+ worldbook: snapshot.worldbook,
14271
+ worldbookPresent: snapshot.worldbookPresent,
14272
+ worldbookError: snapshot.worldbookError,
14273
+ world: snapshot.world,
14274
+ chapters: archived,
14275
+ characters: snapshot.characters,
14276
+ timeline: snapshot.timeline,
14277
+ foreshadows: snapshot.foreshadows,
14278
+ relationships: snapshot.relationships,
14279
+ reviews: currentReviews,
14280
+ activity: journal.rows.filter((row) => row.op !== "lock_reclaimed").slice(-8),
14281
+ journal: journal.rows,
14282
+ reviewAttempts: attempts,
14283
+ finalChecks: Object.fromEntries(archived.filter((record) => record.final_check === "pass" || record.final_check === "fail").map((record) => [record.num, record.final_check])),
14284
+ targetChapters: target,
14285
+ minScore: this.config.quality.minScore,
14286
+ dimensionMax: Object.fromEntries(this.rulepack.quality.dimensions.map((dimension) => [dimension.id, dimension.max])),
14287
+ foreshadowDormantAfter: this.rulepack.crosschapter.foreshadow_dormant_after
14288
+ });
14289
+ if (!includeQuality) {
14290
+ const baseScores = archived.map((chapter) => chapter.score);
14291
+ const sorted = [...baseScores].sort((left, right) => left - right);
14292
+ const middle = Math.floor(sorted.length / 2);
14293
+ const median = sorted.length === 0 ? 0 : sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
14294
+ panelReport.quality = {
14295
+ ...panelReport.quality,
14296
+ average: baseScores.length === 0 ? 0 : baseScores.reduce((total, score) => total + score, 0) / baseScores.length,
14297
+ median,
14298
+ min: baseScores.length === 0 ? 0 : Math.min(...baseScores),
14299
+ max: baseScores.length === 0 ? 0 : Math.max(...baseScores),
14300
+ last_five: baseScores.slice(-5),
14301
+ trend: null,
14302
+ trend_basis: "insufficient_samples",
14303
+ samples: 0,
14304
+ attempts: 0,
14305
+ dimensions: []
14306
+ };
14307
+ }
13507
14308
  const bookData = buildPanelBookData({
13508
- report: buildBookReport({
13509
- project: snapshot.project,
13510
- outline: snapshot.outline,
13511
- outlinePresent: snapshot.outlinePresent,
13512
- outlineError: snapshot.outlineError,
13513
- worldbook: snapshot.worldbook,
13514
- worldbookPresent: snapshot.worldbookPresent,
13515
- worldbookError: snapshot.worldbookError,
13516
- world: snapshot.world,
13517
- chapters: archived,
13518
- characters: snapshot.characters,
13519
- timeline: snapshot.timeline,
13520
- foreshadows: snapshot.foreshadows,
13521
- relationships: snapshot.relationships,
13522
- reviews: currentReviews,
13523
- activity: journal.rows.slice(-8),
13524
- journal: journal.rows,
13525
- reviewAttempts: attempts,
13526
- finalChecks: Object.fromEntries(archived.filter((record) => record.final_check === "pass" || record.final_check === "fail").map((record) => [record.num, record.final_check])),
13527
- targetChapters: target,
13528
- minScore: this.config.quality.minScore,
13529
- foreshadowDormantAfter: this.rulepack.crosschapter.foreshadow_dormant_after
13530
- }),
14309
+ report: panelReport,
13531
14310
  project: snapshot.project,
13532
14311
  worldbook: snapshot.worldbook,
13533
14312
  world: snapshot.world,
@@ -13538,7 +14317,7 @@ var EngineService = class {
13538
14317
  stage: snapshot.pipeline.stage,
13539
14318
  rounds: snapshot.pipeline.rounds,
13540
14319
  draftWords,
13541
- lastTenAverage: scores.length === 0 ? 0 : scores.reduce((total, score) => total + score, 0) / scores.length,
14320
+ lastTenAverage: scores.length === 0 ? null : scores.reduce((total, score) => total + score, 0) / scores.length,
13542
14321
  manuscriptHash: snapshot.pipeline.draft?.content_hash ?? (snapshot.text === null ? null : sha256(snapshot.text)),
13543
14322
  metrics: draftMetrics,
13544
14323
  stateErrors: this.stateErrors(snapshot).map((error) => ({
@@ -13558,7 +14337,7 @@ var EngineService = class {
13558
14337
  num: snapshot.pipeline.chapter,
13559
14338
  title: stripTitlePrefix(snapshot.plan?.title ?? "") || `第${snapshot.pipeline.chapter}章`,
13560
14339
  wordCount: draftWords,
13561
- score: snapshot.review?.total ?? 0,
14340
+ score: snapshot.review?.total ?? null,
13562
14341
  status: blocking.length > 0 ? "blocked" : "draft",
13563
14342
  details: (snapshot.pipeline.draft?.summary?.events ?? []).join(";").slice(0, 240)
13564
14343
  };
@@ -13569,6 +14348,7 @@ var EngineService = class {
13569
14348
  platform: snapshot.project.platform,
13570
14349
  chapter: snapshot.pipeline.chapter,
13571
14350
  stage: snapshot.pipeline.stage,
14351
+ progress,
13572
14352
  revision: snapshot.pipeline.revision,
13573
14353
  rounds: snapshot.pipeline.rounds,
13574
14354
  blocking,
@@ -13589,7 +14369,9 @@ var EngineService = class {
13589
14369
  finished,
13590
14370
  chapters: archived,
13591
14371
  draft,
13592
- totalWords: archived.reduce((total, chapter) => total + chapter.word_count, 0) + (draft === null ? 0 : draft.wordCount),
14372
+ totalWords: archivedWords + draftWords,
14373
+ archivedWords,
14374
+ draftWords,
13593
14375
  averageScore: archived.length === 0 ? 0 : archived.reduce((total, chapter) => total + chapter.score, 0) / archived.length,
13594
14376
  phases: phaseStrip(snapshot.pipeline.stage, finished),
13595
14377
  book,
@@ -13697,6 +14479,12 @@ var EngineService = class {
13697
14479
  nextStep(snapshot) {
13698
14480
  const stage = snapshot.pipeline.stage;
13699
14481
  const chapter = snapshot.pipeline.chapter;
14482
+ const progress = projectChapterProgress({
14483
+ activeChapter: chapter,
14484
+ stage,
14485
+ archived: snapshot.chapters.chapters.map((record) => record.num),
14486
+ target: snapshot.project.targets.total_chapters
14487
+ });
13700
14488
  if (snapshot.chapters.chapters.some((record) => record.num === chapter)) return {
13701
14489
  tool: "novel_amend",
13702
14490
  args: { path: joinRel(STATE_DIR, STATE_FILES.chapters) },
@@ -13747,8 +14535,7 @@ var EngineService = class {
13747
14535
  };
13748
14536
  }
13749
14537
  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 {
14538
+ if (progress.finished) return {
13752
14539
  tool: "novel_export",
13753
14540
  args: { kind: "epub" },
13754
14541
  reason: "全书已完成;可导出成书,或结束本书"
@@ -14356,6 +15143,7 @@ var EngineService = class {
14356
15143
  briefStage: extras.briefStage ?? briefStageFor(operation, snapshot.pipeline.stage),
14357
15144
  draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.pipeline.draft?.title ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
14358
15145
  sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
15146
+ narrator: extras.narrator ?? snapshot.pipeline.draft?.narrator ?? snapshot.plan?.narrator ?? null,
14359
15147
  summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
14360
15148
  summaryProvided: extras.summaryProvided,
14361
15149
  summaryOnly: extras.summaryOnly,
@@ -15087,6 +15875,29 @@ const machineWarnings = (machine, acknowledged = []) => machine.violations.filte
15087
15875
  message: `${violation.detail} (paragraph ${violation.location.paragraph})`
15088
15876
  }));
15089
15877
  /** 0.1.2 archive digests used localeCompare; keep that comparison only for upgrades. */
15878
+ const archivedCardSnapshot = (card) => ({
15879
+ title: card.title,
15880
+ scene_type: card.scene_type,
15881
+ target_words: [card.target_words[0], card.target_words[1]],
15882
+ cast: [...card.cast],
15883
+ goal: card.goal,
15884
+ conflict: card.conflict,
15885
+ hook: card.hook,
15886
+ notes: card.notes,
15887
+ archived_at: card.archived_at
15888
+ });
15889
+ const archivedCardDigest = (card) => `v1:${sha256(stableJson(card))}`;
15890
+ const archivedCardRecord = (record) => {
15891
+ const card = record.archive_card;
15892
+ if (card === void 0) return {};
15893
+ return {
15894
+ num: record.num,
15895
+ ...card,
15896
+ status: "archived",
15897
+ word_count: record.word_count,
15898
+ score: record.score
15899
+ };
15900
+ };
15090
15901
  const legacyStableJson = (value) => {
15091
15902
  const normalize = (input) => {
15092
15903
  if (Array.isArray(input)) return input.map(normalize);
@@ -15236,6 +16047,9 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
15236
16047
  }
15237
16048
  if (before.content_hash !== after.content_hash) problems.push(`archived chapter ${String(before.num)} cannot change its content_hash`);
15238
16049
  if (before.archive_digest !== after.archive_digest) problems.push(`archived chapter ${String(before.num)} cannot change its archive_digest`);
16050
+ if (before.archive_card === void 0 && after.archive_card !== void 0) problems.push(`archived chapter ${String(before.num)} cannot add archive_card through amend`);
16051
+ else if (before.archive_card !== void 0 && (after.archive_card === void 0 || stableJson(before.archive_card) !== stableJson(after.archive_card))) problems.push(`archived chapter ${String(before.num)} cannot change its archive_card`);
16052
+ if (before.archive_card_digest !== after.archive_card_digest) problems.push(`archived chapter ${String(before.num)} cannot change its archive_card_digest`);
15239
16053
  if (stableJson(before.assigned_ids ?? {}) !== stableJson(after.assigned_ids ?? {})) problems.push(`archived chapter ${String(before.num)} cannot change its assigned_ids`);
15240
16054
  for (const field of [
15241
16055
  "title",
@@ -15410,6 +16224,10 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
15410
16224
  }
15411
16225
  const indexed = new Set(snapshot.chapters.chapters.map((record) => record.num));
15412
16226
  const chaptersUnreadable = snapshot.ledgerErrors.some((error) => error.code === "CHAPTERS_UNREADABLE");
16227
+ if (chaptersUnreadable) {
16228
+ for (const [num, card] of byNum) if (card.status === "archived" && !oldCards.has(num)) problems.push(`cannot verify archived chapter ${num} while chapters.yaml is unreadable; restore the chapter index from backups/ first`);
16229
+ for (const num of archivedChapterNumbers ?? []) if (byNum.get(num)?.status !== "archived") problems.push(`cannot verify archived chapter ${num} while chapters.yaml is unreadable; keep its archived card`);
16230
+ }
15413
16231
  if (!chaptersUnreadable) {
15414
16232
  for (const [num, card] of byNum) if (card.status === "archived" && !indexed.has(num)) problems.push(`archived chapter ${num} has a card but is not in the chapter index`);
15415
16233
  }
@@ -15446,6 +16264,12 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
15446
16264
  problems.push(`archived chapter ${record.num} has no previous outline card; restore its archive backup before repairing target_words/cast/archived_at`);
15447
16265
  continue;
15448
16266
  }
16267
+ for (const field of [
16268
+ "goal",
16269
+ "conflict",
16270
+ "hook",
16271
+ "notes"
16272
+ ]) if (stableJson(before[field]) !== stableJson(card[field])) problems.push(`archived chapter ${record.num} cannot change its ${field}`);
15449
16273
  if (JSON.stringify(before.target_words) !== JSON.stringify(card.target_words)) problems.push(`archived chapter ${record.num} cannot change its target_words`);
15450
16274
  const beforeCast = Array.isArray(before.cast) ? before.cast : [];
15451
16275
  const afterCast = Array.isArray(card.cast) ? card.cast : [];
@@ -15457,6 +16281,27 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
15457
16281
  for (const [num, card] of oldCards) if (card.status === "archived") expected.add(num);
15458
16282
  const missing = [...expected].filter((num) => byNum.get(num)?.status !== "archived");
15459
16283
  if (missing.length > 0) problems.push(`cannot verify archived cards while chapters.yaml is unreadable; keep archived cards for ${missing.join(", ")}`);
16284
+ for (const [num, before] of oldCards) {
16285
+ if (before.status !== "archived") continue;
16286
+ const card = byNum.get(num);
16287
+ if (card === void 0) continue;
16288
+ if (card.status !== "archived") problems.push(`archived chapter ${num} cannot be changed back to planned`);
16289
+ for (const field of [
16290
+ "title",
16291
+ "scene_type",
16292
+ "word_count",
16293
+ "score",
16294
+ "goal",
16295
+ "conflict",
16296
+ "hook",
16297
+ "notes"
16298
+ ]) if (stableJson(before[field]) !== stableJson(card[field])) problems.push(`archived chapter ${num} cannot change its ${field}`);
16299
+ if (JSON.stringify(before.target_words) !== JSON.stringify(card.target_words)) problems.push(`archived chapter ${num} cannot change its target_words`);
16300
+ const beforeCast = Array.isArray(before.cast) ? before.cast : [];
16301
+ const afterCast = Array.isArray(card.cast) ? card.cast : [];
16302
+ if (beforeCast.length !== afterCast.length || afterCast.some((name) => !beforeCast.includes(name))) problems.push(`archived chapter ${num} cannot change its cast`);
16303
+ 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`);
16304
+ }
15460
16305
  }
15461
16306
  return problems;
15462
16307
  }
@@ -15676,16 +16521,17 @@ function diffLines(before, after) {
15676
16521
  const arraySemanticsDescription = (semantics, description) => `[x-semantics: ${semantics}] ${description}`;
15677
16522
  // src/tools/index.ts
15678
16523
  /**
15679
- * Providers sometimes serialize a free-form `json` parameter as a JSON string
15680
- * even when the model intended an object. Accept both wire shapes; an invalid
15681
- * string becomes a machine-code error instead of a raw TypeError.
16524
+ * Compatibility helper for already-serialized legacy payloads and dynamic
16525
+ * maps. Model-facing schemas are typed and reject strings before this runs;
16526
+ * the string branch is kept only for old callers that invoke the adapter
16527
+ * directly.
15682
16528
  */
15683
16529
  const asJson = (value, field) => {
15684
16530
  if (typeof value !== "string") return value;
15685
16531
  try {
15686
16532
  return JSON.parse(value);
15687
16533
  } catch {
15688
- throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be a JSON object or a valid JSON string`, `send ${field} as an object, not a malformed string`);
16534
+ throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be a JSON object`, `send ${field} as an object, not a serialized string`);
15689
16535
  }
15690
16536
  };
15691
16537
  /** Turn an engine failure into the message shape tools surface. */
@@ -15755,17 +16601,255 @@ const stringArray = (semantics, description) => ({
15755
16601
  items: { type: "string" },
15756
16602
  description: arraySemanticsDescription(semantics, description)
15757
16603
  });
16604
+ const str = { type: "string" };
16605
+ const int = { type: "integer" };
16606
+ const num$1 = { type: "number" };
16607
+ const bool$1 = { type: "boolean" };
16608
+ const nullableInt = { oneOf: [{ type: "integer" }, { type: "null" }] };
16609
+ const nullableStr = { oneOf: [{ type: "string" }, { type: "null" }] };
16610
+ const required = (value) => ({
16611
+ ...value,
16612
+ required: true
16613
+ });
16614
+ const optional = (value) => {
16615
+ const copy = { ...value };
16616
+ delete copy.required;
16617
+ return copy;
16618
+ };
16619
+ const arrayOf = (items) => ({
16620
+ type: "array",
16621
+ items
16622
+ });
16623
+ const objectOf = (properties) => ({
16624
+ type: "object",
16625
+ additionalProperties: false,
16626
+ properties
16627
+ });
16628
+ const openObject = (description) => ({
16629
+ type: "object",
16630
+ additionalProperties: true,
16631
+ ...description === void 0 ? {} : { description }
16632
+ });
16633
+ const openObjectWith = (properties, description) => ({
16634
+ type: "object",
16635
+ additionalProperties: true,
16636
+ properties,
16637
+ ...description === void 0 ? {} : { description }
16638
+ });
16639
+ const readingProofRef = objectOf({
16640
+ chapter: int,
16641
+ paragraph: required(int),
16642
+ quote: required(str)
16643
+ });
16644
+ const proofSchema = required(objectOf({
16645
+ opening: required(readingProofRef),
16646
+ middle: required(readingProofRef),
16647
+ ending: required(readingProofRef)
16648
+ }));
16649
+ const issueSchema = objectOf({
16650
+ id: required(str),
16651
+ severity: required({
16652
+ type: "string",
16653
+ enum: ["blocker", "advisory"]
16654
+ }),
16655
+ dimension: required(str),
16656
+ location: required(str),
16657
+ problem: required(str),
16658
+ fix: required(str),
16659
+ status: required({
16660
+ type: "string",
16661
+ enum: [
16662
+ "open",
16663
+ "fixed",
16664
+ "waived"
16665
+ ]
16666
+ }),
16667
+ evidence: str
16668
+ });
16669
+ const issueUpdateSchema = objectOf({
16670
+ id: required(str),
16671
+ status: required({
16672
+ type: "string",
16673
+ enum: [
16674
+ "open",
16675
+ "fixed",
16676
+ "waived"
16677
+ ]
16678
+ }),
16679
+ evidence: str,
16680
+ severity: {
16681
+ type: "string",
16682
+ enum: ["blocker", "advisory"]
16683
+ },
16684
+ dimension: str,
16685
+ location: str,
16686
+ problem: str,
16687
+ fix: str
16688
+ });
16689
+ const dimensionSchema = objectOf({
16690
+ id: required(str),
16691
+ max: required(int),
16692
+ deductions: required(arrayOf(objectOf({
16693
+ rule: required(str),
16694
+ location: required(str),
16695
+ delta: required(int),
16696
+ note: required(str)
16697
+ })))
16698
+ });
16699
+ const summarySchema = required(objectOf({
16700
+ events: required(arrayOf(str)),
16701
+ characters_appeared: required(arrayOf(str)),
16702
+ foreshadows: required(objectOf({
16703
+ planted: required(arrayOf(objectOf({
16704
+ key: required(str),
16705
+ title: required(str),
16706
+ description: required(str),
16707
+ anchor: str
16708
+ }))),
16709
+ advanced: required(arrayOf(objectOf({
16710
+ key: required(str),
16711
+ note: required(str),
16712
+ new_info: required(bool$1)
16713
+ }))),
16714
+ resolved: required(arrayOf(str))
16715
+ })),
16716
+ new_elements: required(objectOf({
16717
+ locations: required(arrayOf(str)),
16718
+ abilities: required(arrayOf(str)),
16719
+ artifacts: required(arrayOf(str))
16720
+ })),
16721
+ new_element_anchors: openObject("实体名到正文锚点数组的动态映射;值形状由引擎校验"),
16722
+ resource_changes: required(arrayOf(objectOf({
16723
+ character: required(str),
16724
+ resource: required(str),
16725
+ delta: required(int),
16726
+ reason: required(str)
16727
+ }))),
16728
+ opening: required(str),
16729
+ continuity: required(objectOf({
16730
+ opens_from: required(str),
16731
+ ends_with: required(str)
16732
+ })),
16733
+ hook_strength: required({
16734
+ type: "integer",
16735
+ enum: [
16736
+ 1,
16737
+ 2,
16738
+ 3,
16739
+ 4,
16740
+ 5
16741
+ ]
16742
+ }),
16743
+ pleasure_points: required(arrayOf(str)),
16744
+ plot_advancement: required({
16745
+ type: "string",
16746
+ enum: [
16747
+ "high",
16748
+ "mid",
16749
+ "low"
16750
+ ]
16751
+ })
16752
+ }));
16753
+ const volumeSchema = objectOf({
16754
+ id: str,
16755
+ title: required(str),
16756
+ start_chapter: required(int),
16757
+ end_chapter: required(int),
16758
+ goal: str,
16759
+ arc: str,
16760
+ climax: str,
16761
+ status: {
16762
+ type: "string",
16763
+ enum: [
16764
+ "planned",
16765
+ "writing",
16766
+ "done"
16767
+ ]
16768
+ }
16769
+ });
16770
+ const chapterCardSchema = openObjectWith({
16771
+ num: required(int),
16772
+ volume: nullableInt,
16773
+ title: required(str),
16774
+ scene_type: required({
16775
+ type: "string",
16776
+ enum: SCENE_TYPES
16777
+ }),
16778
+ goal: required(str),
16779
+ conflict: required(str),
16780
+ hook: required(str),
16781
+ cast: arrayOf(str),
16782
+ target_words: required(arrayOf(int)),
16783
+ notes: str,
16784
+ status: {
16785
+ type: "string",
16786
+ enum: ["planned", "archived"]
16787
+ },
16788
+ archived_at: nullableStr,
16789
+ word_count: nullableInt,
16790
+ score: nullableInt
16791
+ });
16792
+ const dynamicSection = (description) => openObject(description);
16793
+ const stateDeltaSchema = required(objectOf({
16794
+ characters: required(dynamicSection("人物名到 {action,reason?,role?,profile?,current_state?,growth?} 的映射")),
16795
+ foreshadows: required(dynamicSection("key 到 {action,title?,anchor?,description?,note?,new_info?} 的映射")),
16796
+ timeline: required(dynamicSection("summary.events 下标到 {action,story_time?,description?,location?,characters?} 的映射")),
16797
+ world: required(dynamicSection("名称到 {action,kind?,description?,layout?,capacity?} 的映射")),
16798
+ relationships: required(dynamicSection("\"甲|乙\" 到 {action,type?,description?,note?} 的映射"))
16799
+ }));
16800
+ const handoffSchema = objectOf({
16801
+ volume: int,
16802
+ volume_name: str,
16803
+ act: str,
16804
+ act_progress: str
16805
+ });
16806
+ const worldbookRule = objectOf({
16807
+ name: required(str),
16808
+ description: required(str)
16809
+ });
16810
+ const worldbookFaction = objectOf({
16811
+ name: required(str),
16812
+ description: required(str),
16813
+ goal: str
16814
+ });
16815
+ const worldbookLocation = objectOf({
16816
+ name: required(str),
16817
+ description: required(str),
16818
+ layout: str,
16819
+ capacity: int
16820
+ });
16821
+ const worldbookCharacter = objectOf({
16822
+ name: required(str),
16823
+ role: required(str),
16824
+ description: required(str),
16825
+ arc: str
16826
+ });
16827
+ const worldbookGlossary = objectOf({
16828
+ term: required(str),
16829
+ definition: required(str)
16830
+ });
16831
+ const worldbookBackstory = objectOf({
16832
+ time: required(str),
16833
+ event: required(str)
16834
+ });
16835
+ const worldbookFact = objectOf({
16836
+ alias: required(str),
16837
+ value: required(num$1),
16838
+ unit: required(str),
16839
+ tolerance: num$1,
16840
+ note: str
16841
+ });
15758
16842
  const root = {
15759
16843
  type: "string",
15760
- description: "Project root. Passing it selects this project for the current session and persists the choice. When omitted: session binding -> unique project in the workspace -> single registered project -> configured projectRoot -> error. Multiple projects require an explicit choice."
16844
+ description: "Project root; omit to use the session binding or the only project."
15761
16845
  };
15762
16846
  const initRoot = {
15763
16847
  type: "string",
15764
- description: "Project root for the new project. Passing it creates the project there. When omitted, only the configured projectRoot is used; novel_init does not follow the session binding, workspace discovery or registry active. The created project is bound to the current session on success."
16848
+ description: "New project root; omit to use the configured projectRoot."
15765
16849
  };
15766
16850
  const utilityRoot = {
15767
16851
  type: "string",
15768
- description: "Project root for the project-backed mode of this tool. Text-only word_count/diff calls ignore it and do not create a session binding; use a project-backed call when you want to select a book."
16852
+ description: "Project root for project-backed mode; text-only calls ignore it."
15769
16853
  };
15770
16854
  /**
15771
16855
  * The twenty-eight tools are thin adapters: narrow parameters, call one service
@@ -15847,9 +16931,11 @@ function createNovelTools(engine, options = {}) {
15847
16931
  render: (_args, raw) => {
15848
16932
  const value = payload(raw);
15849
16933
  const active = value.active_chapter ?? value.chapter;
15850
- const where = active === value.chapter ? `第 ${value.chapter} 章` : `当前第 ${active} 章 · 查询第 ${value.chapter} 章字数`;
16934
+ const progressLabel = value.progress?.label ?? `第 ${value.chapter} 章 · 阶段 ${value.stage}`;
15851
16935
  return text([
15852
- `《${value.project.title}》 ${where} · ${value.stage}`,
16936
+ `《${value.project.title}》`,
16937
+ `进度:${progressLabel}`,
16938
+ ...active === value.chapter ? [] : [`字数查询:第 ${value.chapter} 章(活动章第 ${active} 章)`],
15853
16939
  ...value.selection === void 0 ? [] : [`项目根 ${value.selection.root} · 选择来源 ${value.selection.source}`],
15854
16940
  ...value.rounds === void 0 ? [] : [`轮次:评审 ${value.rounds.review} / 修订 ${value.rounds.revise} / 校验 ${value.rounds.check}`],
15855
16941
  value.blocking.length === 0 ? "门控:全部通过" : `门控:${value.blocking.length} 条阻塞`,
@@ -15908,11 +16994,19 @@ function createNovelTools(engine, options = {}) {
15908
16994
  },
15909
16995
  constraints: stringArray("replace", "P1 constraints; supplied list replaces the plan list."),
15910
16996
  characters: {
15911
- type: "json",
16997
+ ...arrayOf(objectOf({
16998
+ name: required(str),
16999
+ role: required(str),
17000
+ current_state: required(str)
17001
+ })),
15912
17002
  description: arraySemanticsDescription("replace", "P1 cast; supplied list replaces the cast.")
15913
17003
  },
15914
17004
  locations: {
15915
- type: "json",
17005
+ ...arrayOf(objectOf({
17006
+ name: required(str),
17007
+ layout: required(str),
17008
+ capacity: required(num$1)
17009
+ })),
15916
17010
  description: arraySemanticsDescription("replace", "P1 locations; supplied list replaces them.")
15917
17011
  },
15918
17012
  continuity_notes: stringArray("replace", "P1 continuity notes; supplied list replaces them."),
@@ -15965,8 +17059,14 @@ function createNovelTools(engine, options = {}) {
15965
17059
  description: arraySemanticsDescription("replace", "Plan [min,max] word target.")
15966
17060
  },
15967
17061
  cast: {
15968
- type: "json",
15969
- description: arraySemanticsDescription("replace", "Optional 1–8 characters on stage this chapter. Each: name, entry_state, goal, knowledge, exit_change. Names must exist in the P1 world plan or the character ledger.")
17062
+ ...arrayOf(objectOf({
17063
+ name: required(str),
17064
+ entry_state: str,
17065
+ goal: str,
17066
+ knowledge: arrayOf(str),
17067
+ exit_change: str
17068
+ })),
17069
+ description: arraySemanticsDescription("replace", "Optional 1–8 characters on stage this chapter; names must exist in the P1 world plan or the character ledger.")
15970
17070
  },
15971
17071
  project_root: root
15972
17072
  },
@@ -16010,9 +17110,8 @@ function createNovelTools(engine, options = {}) {
16010
17110
  required: true
16011
17111
  },
16012
17112
  summary: {
16013
- type: "json",
16014
- required: true,
16015
- description: "The ten-field chapter summary required by PG-05. Pass an object (events, characters_appeared, foreshadows, new_elements, resource_changes, opening, continuity, hook_strength, pleasure_points, plot_advancement); a JSON string is accepted too."
17113
+ ...summarySchema,
17114
+ description: "The ten-field chapter summary required by PG-05; send a JSON object, not a JSON string."
16016
17115
  },
16017
17116
  project_root: root
16018
17117
  },
@@ -16036,12 +17135,12 @@ function createNovelTools(engine, options = {}) {
16036
17135
  required: true
16037
17136
  },
16038
17137
  dimensions: {
16039
- type: "json",
17138
+ ...arrayOf(dimensionSchema),
16040
17139
  required: true,
16041
17140
  description: arraySemanticsDescription("replace", "Fifteen scored dimensions.")
16042
17141
  },
16043
17142
  issues: {
16044
- type: "json",
17143
+ ...arrayOf(issueSchema),
16045
17144
  description: arraySemanticsDescription("replace", "Review issue list.")
16046
17145
  },
16047
17146
  total: {
@@ -16050,8 +17149,7 @@ function createNovelTools(engine, options = {}) {
16050
17149
  description: "150 - Σdelta, the same number the review reports."
16051
17150
  },
16052
17151
  proof: {
16053
- type: "json",
16054
- required: true,
17152
+ ...proofSchema,
16055
17153
  description: arraySemanticsDescription("replace", "Three reading-proof quotes.")
16056
17154
  },
16057
17155
  project_root: root
@@ -16078,20 +17176,24 @@ function createNovelTools(engine, options = {}) {
16078
17176
  type: "string",
16079
17177
  description: "Full revised text; mutually exclusive with patch."
16080
17178
  },
16081
- patch: {
16082
- type: "json",
16083
- description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
16084
- },
17179
+ patch: objectOf({
17180
+ expected_hash: required(str),
17181
+ edits: required(arrayOf(objectOf({
17182
+ old_text: required(str),
17183
+ new_text: required(str),
17184
+ occurrence: int
17185
+ })))
17186
+ }),
16085
17187
  summary: {
16086
- type: "json",
17188
+ ...optional(summarySchema),
16087
17189
  description: "Full ten-field summary. Omit content+patch with a summary to repair only pipeline.draft.summary; this does not consume a revise round."
16088
17190
  },
16089
17191
  issues: {
16090
- type: "json",
17192
+ ...arrayOf(issueUpdateSchema),
16091
17193
  description: arraySemanticsDescription("replace", "Updated issue statuses and evidence for PG-08.")
16092
17194
  },
16093
17195
  proof: {
16094
- type: "json",
17196
+ ...optional(proofSchema),
16095
17197
  description: arraySemanticsDescription("replace", "Three quotes from the revised text; required unless this is a summary-only repair. PG-09 verifies them.")
16096
17198
  },
16097
17199
  project_root: root
@@ -16117,7 +17219,24 @@ function createNovelTools(engine, options = {}) {
16117
17219
  },
16118
17220
  scope: stringArray("replace", "Requested check scopes; supplied list replaces the request."),
16119
17221
  semantic: {
16120
- type: "json",
17222
+ ...arrayOf(objectOf({
17223
+ id: required({
17224
+ type: "string",
17225
+ enum: [
17226
+ "L2-SCENE-ANCHOR",
17227
+ "L2-CHARACTER-STATE",
17228
+ "L2-CAUSALITY",
17229
+ "L2-VOICE",
17230
+ "L2-POV"
17231
+ ]
17232
+ }),
17233
+ verdict: required({
17234
+ type: "string",
17235
+ enum: ["pass", "fail"]
17236
+ }),
17237
+ evidence: required(str),
17238
+ note: required(str)
17239
+ })),
16121
17240
  required: true,
16122
17241
  description: arraySemanticsDescription("replace", "Exactly the five P6 semantic items, each with a verbatim evidence quote.")
16123
17242
  },
@@ -16146,10 +17265,10 @@ function createNovelTools(engine, options = {}) {
16146
17265
  required: true
16147
17266
  },
16148
17267
  state_delta: {
16149
- type: "json",
16150
- required: true
17268
+ ...stateDeltaSchema,
17269
+ description: "Five ledger sections; entry values are dynamic maps validated by the engine."
16151
17270
  },
16152
- handoff: { type: "json" },
17271
+ handoff: handoffSchema,
16153
17272
  project_root: root
16154
17273
  },
16155
17274
  output: {
@@ -16259,7 +17378,7 @@ function createNovelTools(engine, options = {}) {
16259
17378
  ]
16260
17379
  },
16261
17380
  content: {
16262
- type: "json",
17381
+ ...openObject("按 path 的完整替换文档;根必须是映射。"),
16263
17382
  required: true
16264
17383
  },
16265
17384
  reason: {
@@ -16402,7 +17521,7 @@ function createNovelTools(engine, options = {}) {
16402
17521
  description: "Require a chapter card before P2/P3."
16403
17522
  },
16404
17523
  volumes: {
16405
- type: "json",
17524
+ ...arrayOf(volumeSchema),
16406
17525
  description: arraySemanticsDescription("replace", "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Replaces the current volumes; dropping any volume requires confirm_remove:true.")
16407
17526
  },
16408
17527
  confirm_remove: {
@@ -16432,15 +17551,25 @@ function createNovelTools(engine, options = {}) {
16432
17551
  description: "Upsert volume/chapter cards by key: chapters by num, volumes by title. Omitted cards are preserved; deletion requires remove/remove_volumes. Archived cards are preserved and cannot be rewritten. Use dry_run to preview. On the first submission strict mode defaults on; with an existing card, PG-13 checks title, scene, target words and cast even in non-strict mode.",
16433
17552
  parameters: {
16434
17553
  book: {
16435
- type: "json",
17554
+ ...objectOf({
17555
+ premise: str,
17556
+ logline: str,
17557
+ themes: stringArray("replace", "Book themes; supplied list replaces the theme list."),
17558
+ tone: str,
17559
+ pov: str,
17560
+ audience: str,
17561
+ selling_points: stringArray("replace", "Selling points; supplied list replaces the list."),
17562
+ ending: str,
17563
+ strict: bool$1
17564
+ }),
16436
17565
  description: "Optional partial book blueprint patch."
16437
17566
  },
16438
17567
  volumes: {
16439
- type: "json",
17568
+ ...arrayOf(volumeSchema),
16440
17569
  description: arraySemanticsDescription("upsert", "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Upserts by title; omitted volumes are kept.")
16441
17570
  },
16442
17571
  chapters: {
16443
- type: "json",
17572
+ ...arrayOf(chapterCardSchema),
16444
17573
  description: arraySemanticsDescription("upsert", "Chapter cards: [{num,title,scene_type,goal,conflict,hook,cast?,target_words:[min,max],volume?,notes?}]. Upserts by num; omitted cards are kept.")
16445
17574
  },
16446
17575
  remove: {
@@ -16507,31 +17636,31 @@ function createNovelTools(engine, options = {}) {
16507
17636
  premise: { type: "string" },
16508
17637
  setting: { type: "string" },
16509
17638
  rules: {
16510
- type: "json",
17639
+ ...arrayOf(worldbookRule),
16511
17640
  description: arraySemanticsDescription("replace", "Rules section list.")
16512
17641
  },
16513
17642
  factions: {
16514
- type: "json",
17643
+ ...arrayOf(worldbookFaction),
16515
17644
  description: arraySemanticsDescription("replace", "Factions section list.")
16516
17645
  },
16517
17646
  locations: {
16518
- type: "json",
17647
+ ...arrayOf(worldbookLocation),
16519
17648
  description: arraySemanticsDescription("replace", "Locations section list.")
16520
17649
  },
16521
17650
  characters: {
16522
- type: "json",
17651
+ ...arrayOf(worldbookCharacter),
16523
17652
  description: arraySemanticsDescription("replace", "Characters section list.")
16524
17653
  },
16525
17654
  glossary: {
16526
- type: "json",
17655
+ ...arrayOf(worldbookGlossary),
16527
17656
  description: arraySemanticsDescription("replace", "Glossary section list.")
16528
17657
  },
16529
17658
  backstory: {
16530
- type: "json",
17659
+ ...arrayOf(worldbookBackstory),
16531
17660
  description: arraySemanticsDescription("replace", "Backstory section list.")
16532
17661
  },
16533
17662
  facts: {
16534
- type: "json",
17663
+ ...arrayOf(worldbookFact),
16535
17664
  description: arraySemanticsDescription("replace", "Declared numeric canon: [{alias,value,unit,tolerance?,note?}].")
16536
17665
  },
16537
17666
  themes: stringArray("replace", "Worldbook themes; supplied list replaces the list."),
@@ -16679,6 +17808,9 @@ const NEXT_DETAILS = {
16679
17808
  novel_rollback: "回退到更早阶段重建当前章。",
16680
17809
  novel_amend: "按明确的修复理由修正一份状态文档。",
16681
17810
  novel_audit: "先查看事务日志,确认并恢复损坏的历史。",
17811
+ novel_restore: "按备份清单恢复损坏的状态文档或当前章。",
17812
+ novel_state: "查看状态文档、大纲、世界观或历史原文。",
17813
+ novel_pipeline: "按当前阶段领取下一步工具与参数。",
16682
17814
  novel_export: "导出成书或书级提案。",
16683
17815
  novel_diff: "对比当前正文与上一版或给定文本。",
16684
17816
  novel_word_count: "按引擎字数口径核对正文长度。"
@@ -16803,9 +17935,11 @@ const failure = (error) => {
16803
17935
  const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
16804
17936
  const statusText = async (engine) => {
16805
17937
  const { data } = await engine.getStatus({});
17938
+ const progressLabel = data.progress?.label ?? `第 ${data.chapter} 章 · 阶段 ${data.stage}`;
16806
17939
  return [
16807
17940
  `《${data.project.title}》 ${data.project.genre} · ${data.project.platform}`,
16808
- `第 ${data.chapter} 章 · 阶段 ${data.stage}`,
17941
+ `进度:${progressLabel}`,
17942
+ ...data.chapter === data.active_chapter ? [] : [`字数查询:第 ${data.chapter} 章(活动章第 ${data.active_chapter} 章)`],
16809
17943
  ...data.selection === void 0 ? [] : [`项目根 ${data.selection.root} · 选择来源 ${data.selection.source}`],
16810
17944
  `轮次 评审 ${data.rounds.review} / 修订 ${data.rounds.revise} / 校验 ${data.rounds.check}`,
16811
17945
  data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}(汉字 ${data.metrics.cjk_chars} + 标点 ${data.metrics.punctuation_chars},含标点)`,
@@ -16852,11 +17986,13 @@ function createCommands(engine, host = {}) {
16852
17986
  case "next": {
16853
17987
  const { data } = await engine.advise({});
16854
17988
  const reason = NEXT_DETAILS[data.tool] ?? data.reason;
16855
- return ok(`下一步:${data.tool}\n理由:${reason}${data.blocking.length === 0 ? "" : `\n阻塞 ${data.blocking.length} 条`}`);
17989
+ return ok(`进度:${data.progress?.label ?? `第 ${data.chapter} 章 · 阶段 ${data.stage}`}\n下一步:${data.tool}\n理由:${reason}${data.blocking.length === 0 ? "" : `\n阻塞 ${data.blocking.length} 条`}`);
16856
17990
  }
16857
17991
  case "stats": {
16858
17992
  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}`);
17993
+ const chapterError = (stats.state_errors ?? []).find((error) => error.path === "state/chapters.yaml");
17994
+ if (chapterError !== void 0) return bad(`章节索引不可读(${chapterError.code}):${chapterError.message}\n修复前不提供 0 值统计;先 novel_amend state/chapters.yaml 或从 backups/ 恢复。`);
17995
+ 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}${stats.partial ?? false ? " · 部分状态不可读" : ""}`);
16860
17996
  }
16861
17997
  case "outline": {
16862
17998
  const status = await engine.getStatus({});
@@ -16886,7 +18022,7 @@ function createCommands(engine, host = {}) {
16886
18022
  case "report": {
16887
18023
  const { data } = await engine.report({});
16888
18024
  return ok([
16889
- `进度:${data.progress.archived}/${data.progress.target ?? "—"} 章 · ${data.progress.words} 字 · ${data.progress.percent}%`,
18025
+ `进度:${data.progress.archived}/${data.progress.target ?? "—"} 章 · ${data.progress.words} 字 · ${data.progress.percent === null ? "—" : `${data.progress.percent}%`}`,
16890
18026
  `大纲:${data.outline.planned} 计划 / ${data.outline.archived} 归档 · 漂移 ${data.outline.drift.length} 项`,
16891
18027
  `质量:均分 ${data.quality.average} · 中位 ${data.quality.median} · 趋势 ${data.quality.trend === null ? "—" : `${data.quality.trend >= 0 ? "+" : ""}${data.quality.trend}`} · ${data.quality.samples} 章样本 / ${data.quality.attempts} 次评审`,
16892
18028
  `伏笔:未回收 ${data.foreshadows.open} · 逾期 ${data.foreshadows.overdue}`,
@@ -17000,8 +18136,10 @@ function createCommands(engine, host = {}) {
17000
18136
  }
17001
18137
  case "resume": {
17002
18138
  const { data } = await engine.resumePrompt({});
17003
- if (data.text === null) return bad("当前项目没有可续写的下一步;先用 /novel status 查看项目状态。");
17004
- return ok(data.text);
18139
+ if (data.text !== null) return ok(data.text);
18140
+ const status = await engine.getStatus({});
18141
+ if (status.data.progress?.finished === true) return ok(status.data.progress.resume);
18142
+ return bad("当前项目没有可续写的下一步;先用 /novel status 查看项目状态。");
17005
18143
  }
17006
18144
  case "admin": {
17007
18145
  if (rest.length === 0) return ok(adminHelp());
@@ -17061,7 +18199,9 @@ function createCommands(engine, host = {}) {
17061
18199
  if (invocation.signal.aborted) return bad("已取消");
17062
18200
  try {
17063
18201
  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}`);
18202
+ const chapterError = (stats.state_errors ?? []).find((error) => error.path === "state/chapters.yaml");
18203
+ if (chapterError !== void 0) return bad(`章节索引不可读(${chapterError.code}):${chapterError.message}\n修复前不提供 0 值统计;先 novel_amend state/chapters.yaml 或从 backups/ 恢复。`);
18204
+ 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}${stats.partial ?? false ? " · 部分状态不可读" : ""}`);
17065
18205
  } catch (error) {
17066
18206
  return failure(error);
17067
18207
  }
@@ -17127,10 +18267,34 @@ function createSkill() {
17127
18267
  };
17128
18268
  }
17129
18269
  // src/remote/index.ts
17130
- /** Build the payload for one optional panel query root. */
17131
- async function panelPayload(engine, root) {
18270
+ const SECTION_NAMES = /* @__PURE__ */ new Set([
18271
+ "overview",
18272
+ "chapters",
18273
+ "characters",
18274
+ "foreshadows",
18275
+ "timeline",
18276
+ "relationships",
18277
+ "worldbook",
18278
+ "quality",
18279
+ "quality_detail",
18280
+ "health",
18281
+ "chapter_detail"
18282
+ ]);
18283
+ /** Build the payload for one optional panel query root; section is additive. */
18284
+ async function panelPayload(engine, root, section, query = {}, lean = false) {
18285
+ if (section !== void 0 && SECTION_NAMES.has(section)) return {
18286
+ ...await engine.panelSection({
18287
+ ...root === void 0 ? {} : { root },
18288
+ section,
18289
+ query
18290
+ }),
18291
+ schemaVersion: 6
18292
+ };
17132
18293
  return {
17133
- ...await engine.panelBundle(root === void 0 ? {} : { root }),
18294
+ ...await engine.panelBundle({
18295
+ ...root === void 0 ? {} : { root },
18296
+ lean
18297
+ }),
17134
18298
  schemaVersion: 6
17135
18299
  };
17136
18300
  }
@@ -17143,6 +18307,34 @@ const queryRoot = (url) => {
17143
18307
  return;
17144
18308
  }
17145
18309
  };
18310
+ const queryPanel = (url) => {
18311
+ if (url === void 0 || url === "") return { query: {} };
18312
+ try {
18313
+ const parsed = new URL(url, "http://panel.local");
18314
+ const section = parsed.searchParams.get("section") ?? void 0;
18315
+ const query = {};
18316
+ for (const key of [
18317
+ "search",
18318
+ "status",
18319
+ "scene",
18320
+ "sort",
18321
+ "cursor"
18322
+ ]) {
18323
+ const value = parsed.searchParams.get(key);
18324
+ if (value !== null && value !== "") query[key] = value;
18325
+ }
18326
+ const limit = parsed.searchParams.get("limit");
18327
+ if (limit !== null && /^\d+$/.test(limit)) query.limit = Number(limit);
18328
+ const chapter = parsed.searchParams.get("chapter");
18329
+ if (chapter !== null && /^\d+$/.test(chapter)) query.chapter = Number(chapter);
18330
+ return section === void 0 ? { query } : {
18331
+ section,
18332
+ query
18333
+ };
18334
+ } catch {
18335
+ return { query: {} };
18336
+ }
18337
+ };
17146
18338
  const denyAll = () => 503;
17147
18339
  const rejectionText = {
17148
18340
  401: "unauthorized",
@@ -17169,7 +18361,24 @@ function panelHandler(engine, guard = denyAll) {
17169
18361
  return;
17170
18362
  }
17171
18363
  try {
17172
- const payload = await panelPayload(engine, queryRoot(request.url));
18364
+ const { section, query } = queryPanel(request.url);
18365
+ let lean = false;
18366
+ try {
18367
+ lean = new URL(request.url ?? "", "http://panel.local").searchParams.get("lean") === "1";
18368
+ } catch {}
18369
+ const payload = await panelPayload(engine, queryRoot(request.url), section, query, lean);
18370
+ const etag = typeof payload.etag === "string" ? payload.etag : null;
18371
+ if (etag !== null) {
18372
+ response.setHeader("etag", `"${etag}"`);
18373
+ const ifNoneMatch = request.headers?.["if-none-match"];
18374
+ const candidate = Array.isArray(ifNoneMatch) ? ifNoneMatch[0] : ifNoneMatch;
18375
+ if (candidate === `"${etag}"` || candidate === etag) {
18376
+ response.statusCode = 304;
18377
+ response.setHeader("cache-control", "no-store");
18378
+ response.end("");
18379
+ return;
18380
+ }
18381
+ }
17173
18382
  const body = method === "HEAD" ? "" : JSON.stringify(payload);
17174
18383
  response.statusCode = 200;
17175
18384
  response.setHeader("content-type", "application/json; charset=utf-8");
@@ -17274,7 +18483,7 @@ const DEFAULT_RULEPACK = {
17274
18483
  },
17275
18484
  period_density: {
17276
18485
  min: 1.5,
17277
- max: 8
18486
+ max: 10
17278
18487
  },
17279
18488
  ellipsis_run: {
17280
18489
  max: 1,
@@ -17483,7 +18692,34 @@ function compileRulepack(raw) {
17483
18692
  }
17484
18693
  const genres = object(root.genres, "genres");
17485
18694
  knownKeys(genres, GENRES, "genres");
17486
- for (const name of GENRES) knownKeys(object(genres[name], `genres.${name}`), ["note"], `genres.${name}`);
18695
+ for (const name of GENRES) {
18696
+ const entry = object(genres[name], `genres.${name}`);
18697
+ knownKeys(entry, ["note", "profile"], `genres.${name}`);
18698
+ if (entry.profile !== void 0) {
18699
+ const profile = object(entry.profile, `genres.${name}.profile`);
18700
+ knownKeys(profile, [
18701
+ "period_density",
18702
+ "water_words",
18703
+ "pleasure_density"
18704
+ ], `genres.${name}.profile`);
18705
+ if (profile.period_density !== void 0) {
18706
+ const density = object(profile.period_density, `genres.${name}.profile.period_density`);
18707
+ knownKeys(density, ["min", "max"], `genres.${name}.profile.period_density`);
18708
+ const min = num(density.min, `genres.${name}.profile.period_density.min`);
18709
+ const max = num(density.max, `genres.${name}.profile.period_density.max`);
18710
+ if (min < 0 || max < 0) invalid(`genres.${name}.profile.period_density must be non-negative`);
18711
+ if (max > 100) invalid(`genres.${name}.profile.period_density.max must not exceed 100 per 100 chars`);
18712
+ if (min > max) invalid(`genres.${name}.profile.period_density.min must not exceed max`);
18713
+ }
18714
+ if (profile.water_words !== void 0) {
18715
+ const value = num(profile.water_words, `genres.${name}.profile.water_words`);
18716
+ if (value < 0 || value > 1) invalid(`genres.${name}.profile.water_words must be 0..1`);
18717
+ }
18718
+ if (profile.pleasure_density !== void 0) {
18719
+ if (num(profile.pleasure_density, `genres.${name}.profile.pleasure_density`) <= 0) invalid(`genres.${name}.profile.pleasure_density must be > 0`);
18720
+ }
18721
+ }
18722
+ }
17487
18723
  section("crosschapter", [
17488
18724
  "duplicate_similarity",
17489
18725
  "duplicate_advisory",
@@ -17567,7 +18803,7 @@ function compileRulepack(raw) {
17567
18803
  textList(array(conclusionFirst.causals, "lexicon.conclusion_first.causals"), "lexicon.conclusion_first.causals");
17568
18804
  for (const genre of GENRES) {
17569
18805
  const guide = object(pack.genres[genre], `genres.${genre}`);
17570
- knownKeys(guide, ["note"], `genres.${genre}`);
18806
+ knownKeys(guide, ["note", "profile"], `genres.${genre}`);
17571
18807
  if (typeof guide.note !== "string") invalid(`genres.${genre}.note must be a string`);
17572
18808
  }
17573
18809
  const nonNegative = (value, path) => {
@@ -17837,4 +19073,4 @@ function connectionFence(scope) {
17837
19073
  }
17838
19074
  if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
17839
19075
  }
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 };
19076
+ 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, PANEL_VERSION, 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 };