dsh-novel-write-engine 0.2.8 → 0.2.10

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.10";
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. */
@@ -1149,7 +1151,7 @@ const dashBudget = (ctx) => {
1149
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}`)];
1150
1152
  };
1151
1153
  const periodDensity = (ctx) => {
1152
- 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;
1153
1155
  const dominant = ctx.rulepack.punctuation.dialogue_dominant_ratio;
1154
1156
  const results = [];
1155
1157
  splitParagraphs(ctx.text).forEach((paragraph, index) => {
@@ -1382,10 +1384,11 @@ const numericFactMismatch = (ctx) => {
1382
1384
  for (const fact of ctx.facts) {
1383
1385
  if (fact.alias.trim() === "" || !Number.isFinite(fact.value)) continue;
1384
1386
  const tolerance = fact.tolerance ?? 0;
1385
- 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");
1386
1388
  for (const match of ctx.text.matchAll(pattern)) {
1387
- const value = parseSmallNumber(match[1] ?? "");
1388
- 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);
1389
1392
  if (Math.abs(value - fact.value) <= tolerance) continue;
1390
1393
  const paragraph = paragraphs.findIndex((candidate) => candidate.includes(match[0]));
1391
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}`));
@@ -1395,7 +1398,8 @@ const numericFactMismatch = (ctx) => {
1395
1398
  return results;
1396
1399
  };
1397
1400
  const pleasureDensity = (ctx) => {
1398
- 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;
1399
1403
  if (keywords.length === 0) return [];
1400
1404
  const results = [];
1401
1405
  if (slidingWindows(ctx.text, window).findIndex((slice) => {
@@ -1412,8 +1416,9 @@ const sensoryDensity = (ctx) => {
1412
1416
  };
1413
1417
  const waterWords = (ctx) => {
1414
1418
  const { window, threshold, terms, scene_thresholds } = ctx.rulepack.semantics.water_words;
1419
+ const genreThreshold = ctx.rulepack.genres[ctx.genre]?.profile?.water_words;
1415
1420
  if (terms.length === 0) return [];
1416
- const sceneThreshold = scene_thresholds?.[ctx.sceneType] ?? threshold;
1421
+ const sceneThreshold = genreThreshold ?? scene_thresholds?.[ctx.sceneType] ?? threshold;
1417
1422
  const results = [];
1418
1423
  for (const slice of slidingWindows(ctx.text, window)) {
1419
1424
  const density = terms.reduce((total, term) => total + countOccurrences(slice, term), 0) / window;
@@ -1581,25 +1586,24 @@ const resourceMismatch = (ctx) => {
1581
1586
  resourceVisible = !(previousHasNumber && previousHasSign) && previousClauseText.includes(change.resource);
1582
1587
  }
1583
1588
  if (!resourceVisible) continue;
1584
- const names = ctx.summary?.characters_appeared ?? [];
1585
- const otherCharacter = names.some((name) => name !== "" && name !== change.character && clauseText.includes(name));
1586
- let characterVisible = clauseText.includes(change.character);
1587
- 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;
1588
1593
  if (!characterVisible && !otherCharacter && sentence.start > 0) {
1589
1594
  const previous = sentenceBounds(ctx.text, sentence.start - 1, sentence.start - 1);
1590
1595
  const previousText = ctx.text.slice(previous.start, previous.end);
1591
- const named = names.filter((name) => previousText.includes(name));
1596
+ const named = mentionedNames$1(previousText, names);
1592
1597
  characterVisible = /(?:他|她|它|我|他们|她们)/.test(clauseText) && named.length === 1 && named[0] === change.character;
1593
1598
  }
1594
1599
  if (!characterVisible) continue;
1595
1600
  conflict = {
1596
1601
  index,
1597
- amount: match[1] ?? "",
1598
- sign: sign[0]
1602
+ trigger: sign[0]
1599
1603
  };
1600
1604
  break;
1601
1605
  }
1602
- 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}"`)];
1603
1607
  });
1604
1608
  };
1605
1609
  const timeRegression = (ctx) => {
@@ -1623,6 +1627,31 @@ const timeRegression = (ctx) => {
1623
1627
  if (before === null || current >= before) return [];
1624
1628
  return [v(CROSS_RULES.TIME_REGRESSION, "advisory", last.chapter, `story time goes back from ${previous.story_time} to ${last.story_time}`)];
1625
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
+ };
1626
1655
  const relationUnknown = (ctx) => {
1627
1656
  const characters = ctx.evidence.characters;
1628
1657
  const known = characters === null ? [] : characters.characters.map((character) => character.name);
@@ -1634,7 +1663,7 @@ const relationUnknown = (ctx) => {
1634
1663
  if (names.size < 2) return [];
1635
1664
  const results = [];
1636
1665
  for (const [index, paragraph] of splitParagraphs(ctx.text).entries()) {
1637
- const present = [...names].filter((name) => paragraph.includes(name));
1666
+ const present = mentionedNames$1(paragraph, [...names]);
1638
1667
  for (let first = 0; first < present.length; first += 1) for (let second = first + 1; second < present.length; second += 1) {
1639
1668
  const a = present[first];
1640
1669
  const b = present[second];
@@ -1643,7 +1672,7 @@ const relationUnknown = (ctx) => {
1643
1672
  }
1644
1673
  return results;
1645
1674
  };
1646
- const lastDescribedMention = (name, locked, text, order) => {
1675
+ const describedMentions = (name, locked, text) => {
1647
1676
  const candidates = [];
1648
1677
  const strippedLock = locked.split(name).join("");
1649
1678
  for (const [index, paragraph] of splitParagraphs(text).entries()) {
@@ -1659,9 +1688,9 @@ const lastDescribedMention = (name, locked, text, order) => {
1659
1688
  coverage
1660
1689
  });
1661
1690
  }
1662
- if (candidates.length === 0) return null;
1663
- return order === "first" ? candidates[0] : candidates[candidates.length - 1];
1691
+ return candidates;
1664
1692
  };
1693
+ const bestMention = (mentions) => mentions.reduce((best, mention) => best === null || mention.coverage > best.coverage ? mention : best, null);
1665
1694
  const lockedDetailDrift = (ctx) => {
1666
1695
  if (ctx.evidence.locks === null) return [];
1667
1696
  const locks = ctx.evidence.locks;
@@ -1669,16 +1698,16 @@ const lockedDetailDrift = (ctx) => {
1669
1698
  for (const [name, lock] of Object.entries(locks.locations)) {
1670
1699
  if (!ctx.text.includes(name)) continue;
1671
1700
  if (lock.layout.trim() === "") continue;
1672
- const mention = lastDescribedMention(name, lock.layout, ctx.text, "last");
1701
+ const mention = bestMention(describedMentions(name, lock.layout, ctx.text));
1673
1702
  if (mention === null) continue;
1674
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)}"`));
1675
1704
  }
1676
1705
  for (const [name, lock] of Object.entries(locks.characters)) {
1677
1706
  if (!ctx.text.includes(name)) continue;
1678
1707
  if (lock.appearance.trim() === "") continue;
1679
- const mention = lastDescribedMention(name, lock.appearance, ctx.text, "first");
1708
+ const mention = bestMention(describedMentions(name, lock.appearance, ctx.text));
1680
1709
  if (mention === null) continue;
1681
- 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)}"`));
1682
1711
  }
1683
1712
  return results;
1684
1713
  };
@@ -2165,6 +2194,23 @@ const validateSummary = (kind, value, path) => {
2165
2194
  numberAt(kind, summary.hook_strength, `${path}.hook_strength`);
2166
2195
  stringAt(kind, summary.plot_advancement, `${path}.plot_advancement`);
2167
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
+ };
2168
2214
  const validateNested = (kind, record) => {
2169
2215
  switch (kind) {
2170
2216
  case "project": {
@@ -2213,6 +2259,8 @@ const validateNested = (kind, record) => {
2213
2259
  validateSummary(kind, row.summary, `${path}.summary`);
2214
2260
  if (row.assigned_ids !== void 0) objectAt(kind, row.assigned_ids, `${path}.assigned_ids`);
2215
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`);
2216
2264
  optionalString(kind, row.final_check, `${path}.final_check`);
2217
2265
  optionalString(kind, row.archived_at, `${path}.archived_at`);
2218
2266
  if (row.rounds !== void 0) {
@@ -3196,13 +3244,23 @@ var FileLock = class {
3196
3244
  if (record === null) {
3197
3245
  if (Date.now() - mtimeMs <= this.staleMs) return false;
3198
3246
  } else if (lockRecordIsLive(record)) return false;
3199
- const claimPath = `${this.absolutePath}.reclaim-${randomUUID()}`;
3247
+ const claimPath = `${this.absolutePath}.reclaim`;
3200
3248
  let claimByRename = false;
3201
3249
  try {
3202
3250
  await promises.link(this.absolutePath, claimPath);
3203
3251
  } catch (error) {
3204
3252
  const code = error.code;
3205
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
+ }
3206
3264
  if (code === "EPERM" || code === "EACCES") return false;
3207
3265
  if ([
3208
3266
  "ENOSYS",
@@ -4164,7 +4222,7 @@ function outlineSummary(document, archivedCount, target = null, archivedNumbers)
4164
4222
  missingCards,
4165
4223
  plannedCoverage,
4166
4224
  archivedCoverage,
4167
- coverage: plannedCoverage ?? (archivedDenominator === 0 ? 0 : cardCoverage),
4225
+ coverage: plannedCoverage ?? cardCoverage,
4168
4226
  cardCoverage,
4169
4227
  volumes: document.volumes.length
4170
4228
  };
@@ -4784,7 +4842,7 @@ const pick = (values, mentioned, key, limit) => {
4784
4842
  return [...matched, ...rest].slice(0, limit);
4785
4843
  };
4786
4844
  /** Protect the brief budget from a legal-but-huge planning document. */
4787
- const clip = (value, limit) => {
4845
+ const clip$1 = (value, limit) => {
4788
4846
  const chars = Array.from(value);
4789
4847
  return chars.length <= limit ? value : `${chars.slice(0, limit).join("")}…`;
4790
4848
  };
@@ -4793,44 +4851,44 @@ function worldbookBrief(document, mentioned, names) {
4793
4851
  const named = names.join("\n");
4794
4852
  const characters = pick(document.characters, named === "" ? mentioned : named, (entry) => entry.name, 8);
4795
4853
  return {
4796
- premise: clip(document.premise, 600),
4797
- setting: clip(document.setting, 600),
4798
- 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)),
4799
4857
  characters: characters.map((entry) => ({
4800
4858
  name: entry.name,
4801
4859
  role: entry.role,
4802
- description: clip(entry.description, 240),
4803
- arc: clip(entry.arc, 160)
4860
+ description: clip$1(entry.description, 240),
4861
+ arc: clip$1(entry.arc, 160)
4804
4862
  })),
4805
4863
  locations: pick(document.locations, mentioned, (entry) => entry.name, 6).map((entry) => ({
4806
4864
  name: entry.name,
4807
- description: clip(entry.description, 240),
4808
- layout: clip(entry.layout, 120),
4865
+ description: clip$1(entry.description, 240),
4866
+ layout: clip$1(entry.layout, 120),
4809
4867
  capacity: entry.capacity
4810
4868
  })),
4811
4869
  factions: pick(document.factions, mentioned, (entry) => entry.name, 6).map((entry) => ({
4812
4870
  name: entry.name,
4813
- description: clip(entry.description, 240),
4814
- goal: clip(entry.goal, 120)
4871
+ description: clip$1(entry.description, 240),
4872
+ goal: clip$1(entry.goal, 120)
4815
4873
  })),
4816
4874
  rules: pick(document.rules, mentioned, (entry) => entry.name, 6).map((entry) => ({
4817
4875
  name: entry.name,
4818
- description: clip(entry.description, 240)
4876
+ description: clip$1(entry.description, 240)
4819
4877
  })),
4820
4878
  glossary: pick(document.glossary, mentioned, (entry) => entry.term, 6).map((entry) => ({
4821
4879
  term: entry.term,
4822
- definition: clip(entry.definition, 160)
4880
+ definition: clip$1(entry.definition, 160)
4823
4881
  })),
4824
4882
  backstory: document.backstory.slice(0, 3).map((entry) => ({
4825
4883
  time: entry.time,
4826
- event: clip(entry.event, 160)
4884
+ event: clip$1(entry.event, 160)
4827
4885
  })),
4828
4886
  facts: (document.facts ?? []).slice(0, 20).map((fact) => ({
4829
- alias: clip(fact.alias, 120),
4887
+ alias: clip$1(fact.alias, 120),
4830
4888
  value: fact.value,
4831
- unit: clip(fact.unit, 40),
4889
+ unit: clip$1(fact.unit, 40),
4832
4890
  tolerance: fact.tolerance ?? 0,
4833
- ...fact.note === void 0 ? {} : { note: clip(fact.note, 160) }
4891
+ ...fact.note === void 0 ? {} : { note: clip$1(fact.note, 160) }
4834
4892
  }))
4835
4893
  };
4836
4894
  }
@@ -4838,14 +4896,14 @@ function worldbookBrief(document, mentioned, names) {
4838
4896
  function bookBrief(document, chapter) {
4839
4897
  const volume = document.volumes.find((candidate) => chapter >= candidate.start_chapter && chapter <= candidate.end_chapter) ?? null;
4840
4898
  return {
4841
- premise: clip(document.book.premise, 600),
4842
- logline: clip(document.book.logline, 600),
4843
- themes: document.book.themes.slice(0, 12).map((theme) => clip(theme, 80)),
4844
- tone: clip(document.book.tone, 200),
4845
- pov: clip(document.book.pov, 200),
4846
- audience: clip(document.book.audience, 200),
4847
- selling_points: document.book.selling_points.slice(0, 12).map((point) => clip(point, 120)),
4848
- 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),
4849
4907
  strict: document.book.strict,
4850
4908
  volume,
4851
4909
  card: outlineCard(document, chapter)
@@ -4919,9 +4977,11 @@ function parseOutlineMarkdown(content) {
4919
4977
  if (currentVolume === null) return;
4920
4978
  const volume = currentVolume;
4921
4979
  const chapters = chapterInputs.filter((card) => card.volume === volume.order);
4922
- if (chapters.length > 0) {
4980
+ if (chapters.length > 0 && !volume.explicitRange) {
4923
4981
  volume.start_chapter = Math.min(volume.start_chapter, ...chapters.map((card) => card.num));
4924
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");
4925
4985
  } else if (!volume.explicitRange) {
4926
4986
  warnings.push(`volume "${volume.title}" has neither a chapter range nor chapter cards; it was ignored`);
4927
4987
  volumeInputs.pop();
@@ -5204,6 +5264,7 @@ const mergeVolumes = (options) => {
5204
5264
  }
5205
5265
  }
5206
5266
  const byTitle = volumeByTitle(options.existingVolumes);
5267
+ const byId = new Map(options.existingVolumes.map((volume) => [volume.id, volume]));
5207
5268
  const usedIds = new Set(options.existingVolumes.map((volume) => volume.id));
5208
5269
  let nextGeneratedId = 1;
5209
5270
  const generatedId = () => {
@@ -5214,21 +5275,39 @@ const mergeVolumes = (options) => {
5214
5275
  return id;
5215
5276
  };
5216
5277
  const submitted = /* @__PURE__ */ new Set();
5278
+ const submittedIds = /* @__PURE__ */ new Set();
5279
+ const generatedIds = /* @__PURE__ */ new Set();
5217
5280
  for (const [index, raw] of (options.volumes ?? []).entries()) {
5218
5281
  if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
5219
5282
  const title = text$1(raw.title, `volumes[${index}].title`);
5220
5283
  if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains duplicate title "${title}"`);
5221
5284
  submitted.add(title);
5222
- const base = byTitle.get(title);
5223
5285
  const explicitId = typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : void 0;
5224
- 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);
5225
5302
  if (!usedIds.has(id)) usedIds.add(id);
5226
- byTitle.set(title, {
5303
+ const next = {
5227
5304
  ...base ?? {},
5228
5305
  ...raw,
5229
5306
  id,
5230
5307
  title
5231
- });
5308
+ };
5309
+ byTitle.set(title, next);
5310
+ byId.set(id, next);
5232
5311
  }
5233
5312
  for (const title of removed) {
5234
5313
  if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${title}" cannot be both submitted and removed`);
@@ -5236,6 +5315,7 @@ const mergeVolumes = (options) => {
5236
5315
  if (existing === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${title}": it is not in the plan`);
5237
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");
5238
5317
  byTitle.delete(title);
5318
+ byId.delete(existing.id);
5239
5319
  }
5240
5320
  const volumes = normalizeVolumes([...byTitle.values()], { totalChapters: options.totalChapters });
5241
5321
  const before = volumeByTitle(options.existingVolumes);
@@ -6071,6 +6151,11 @@ function applyStateDelta(input) {
6071
6151
  }
6072
6152
  // src/engine/insight.ts
6073
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));
6074
6159
  const median = (values) => {
6075
6160
  if (values.length === 0) return 0;
6076
6161
  const sorted = [...values].sort((left, right) => left - right);
@@ -6118,7 +6203,7 @@ function buildBookReport(input) {
6118
6203
  }
6119
6204
  const chapterReviews = [...finalReviews.values()];
6120
6205
  const qualitySamples = chapterReviews.length;
6121
- const qualityAttempts = Math.max(input.reviewAttempts ?? input.reviews.length, qualitySamples);
6206
+ const qualityAttempts = input.reviewAttempts ?? input.reviews.length;
6122
6207
  const trendBasis = qualitySamples < 3 ? "insufficient_samples" : previous.length < 5 ? "insufficient_history" : "last_five_vs_previous_five";
6123
6208
  const trend = trendBasis === "last_five_vs_previous_five" ? Math.round((average(lastFive) - average(previous)) * 10) / 10 : null;
6124
6209
  const dimensions = /* @__PURE__ */ new Map();
@@ -6223,7 +6308,7 @@ function buildBookReport(input) {
6223
6308
  target: input.targetChapters,
6224
6309
  words,
6225
6310
  average_words: averageWords,
6226
- 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),
6227
6312
  remaining_words: averageRemaining,
6228
6313
  remaining_words_min: minimumRemaining,
6229
6314
  remaining_words_average: averageRemaining,
@@ -6261,7 +6346,8 @@ function buildBookReport(input) {
6261
6346
  dimensions: [...dimensions.entries()].map(([id, value]) => ({
6262
6347
  id,
6263
6348
  average: Math.round(value.total / value.samples * 10) / 10,
6264
- samples: value.samples
6349
+ samples: value.samples,
6350
+ max: input.dimensionMax?.[id] ?? 0
6265
6351
  })).sort((left, right) => compareText(left.id, right.id))
6266
6352
  },
6267
6353
  effort: {
@@ -6669,13 +6755,13 @@ const ledgerWorldEntries = (world, kind) => {
6669
6755
  const nameDescription = (entries, world, kind = "location") => {
6670
6756
  const merged = /* @__PURE__ */ new Map();
6671
6757
  for (const entry of [...entries, ...ledgerWorldEntries(world, kind)]) {
6672
- const name = oneLine(entry.name);
6758
+ const name = clip(oneLine(entry.name), 80);
6673
6759
  if (name === "") continue;
6674
6760
  const existing = merged.get(name);
6675
6761
  if (existing === void 0) {
6676
6762
  merged.set(name, {
6677
6763
  name,
6678
- description: oneLine(entry.description),
6764
+ description: clip(oneLine(entry.description), 240),
6679
6765
  ...entry.anchors === void 0 || entry.anchors.length === 0 ? {} : { anchors: [...entry.anchors] }
6680
6766
  });
6681
6767
  continue;
@@ -6695,12 +6781,12 @@ function buildPanelBookData(input) {
6695
6781
  const card = input.outline.chapters.find((candidate) => candidate.num === input.currentChapter);
6696
6782
  const currentCard = card === void 0 ? null : {
6697
6783
  num: card.num,
6698
- title: card.title,
6784
+ title: clip(card.title, 120),
6699
6785
  scene_type: card.scene_type,
6700
- goal: card.goal,
6701
- conflict: card.conflict,
6702
- hook: card.hook,
6703
- 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)),
6704
6790
  target_words: [card.target_words[0], card.target_words[1]],
6705
6791
  status: card.status
6706
6792
  };
@@ -6717,8 +6803,8 @@ function buildPanelBookData(input) {
6717
6803
  };
6718
6804
  return {
6719
6805
  identity: {
6720
- author: input.project.author,
6721
- description: input.project.description,
6806
+ author: clip(input.project.author, 80),
6807
+ description: clip(input.project.description, 400),
6722
6808
  created_at: input.project.created_at,
6723
6809
  targets: {
6724
6810
  words_per_chapter: [input.project.targets.words_per_chapter[0], input.project.targets.words_per_chapter[1]],
@@ -6727,14 +6813,14 @@ function buildPanelBookData(input) {
6727
6813
  }
6728
6814
  },
6729
6815
  blueprint: {
6730
- premise: input.outline.book.premise,
6731
- logline: input.outline.book.logline,
6732
- themes: [...input.outline.book.themes],
6733
- tone: input.outline.book.tone,
6734
- pov: input.outline.book.pov,
6735
- audience: input.outline.book.audience,
6736
- selling_points: [...input.outline.book.selling_points],
6737
- 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)
6738
6824
  },
6739
6825
  progress: {
6740
6826
  archived: report.progress.archived,
@@ -6760,9 +6846,9 @@ function buildPanelBookData(input) {
6760
6846
  factions: nameDescription(input.worldbook.factions, input.world, "faction"),
6761
6847
  rules: nameDescription(input.worldbook.rules, input.world, "rule"),
6762
6848
  facts: (input.worldbook.facts ?? []).slice(0, 5).map((fact) => ({
6763
- alias: fact.alias,
6849
+ alias: clip(fact.alias, 80),
6764
6850
  value: fact.value,
6765
- unit: fact.unit
6851
+ unit: clip(fact.unit, 40)
6766
6852
  }))
6767
6853
  },
6768
6854
  cast: {
@@ -6805,11 +6891,11 @@ function buildPanelBookData(input) {
6805
6891
  health: {
6806
6892
  state_errors: input.stateErrors.slice(0, 10).map((entry) => ({ ...entry })),
6807
6893
  outline: {
6808
- present: input.outlinePresent,
6894
+ present: input.outlinePresent && input.outlineError === null,
6809
6895
  error: input.outlineError
6810
6896
  },
6811
6897
  worldbook: {
6812
- present: input.worldbookPresent,
6898
+ present: input.worldbookPresent && input.worldbookError === null,
6813
6899
  error: input.worldbookError
6814
6900
  },
6815
6901
  pending: input.pending === null ? null : { ...input.pending }
@@ -6829,7 +6915,7 @@ const kindOf = (directory, suffix) => {
6829
6915
  * still on disk: history is evidence, not a cache, and this function never
6830
6916
  * deletes it.
6831
6917
  */
6832
- async function listHistory(store, chapter) {
6918
+ async function listHistory(store, chapter, options = {}) {
6833
6919
  const entries = [];
6834
6920
  for (const kind of HISTORY_KINDS) {
6835
6921
  const names = await store.list(joinRel(HISTORY_DIR, kind));
@@ -6842,14 +6928,14 @@ async function listHistory(store, chapter) {
6842
6928
  const revision = Number(match.groups.revision);
6843
6929
  if (chapter !== void 0 && num !== chapter) continue;
6844
6930
  const path = joinRel(HISTORY_DIR, kind, name);
6845
- const text = await store.readText(path);
6846
- entries.push({
6931
+ const entry = {
6847
6932
  kind,
6848
6933
  chapter: num,
6849
6934
  revision,
6850
- path,
6851
- chars: text?.length ?? 0
6852
- });
6935
+ path
6936
+ };
6937
+ if (options.withChars === true) entry.chars = (await store.readText(path))?.length ?? 0;
6938
+ entries.push(entry);
6853
6939
  }
6854
6940
  }
6855
6941
  return entries.sort((left, right) => left.chapter - right.chapter || right.revision - left.revision || compareText(left.kind, right.kind));
@@ -8024,7 +8110,7 @@ const SUMMARY_FIELDS = [
8024
8110
  "plot_advancement"
8025
8111
  ];
8026
8112
  /** PG-05: the ten summary fields exist and have the right shape. */
8027
- const summarySchema = (input) => {
8113
+ const summarySchema$1 = (input) => {
8028
8114
  if (input.operation === "submitRevision" && input.summaryProvided !== true) return [];
8029
8115
  if (input.summary === null) return [block("PG-05", "no chapter summary supplied")];
8030
8116
  const summary = input.summary;
@@ -8316,7 +8402,7 @@ const GATE_FUNCTIONS = {
8316
8402
  "PG-02": stageOrder,
8317
8403
  "PG-03": wordCount,
8318
8404
  "PG-04": format,
8319
- "PG-05": summarySchema,
8405
+ "PG-05": summarySchema$1,
8320
8406
  "PG-06": sceneType,
8321
8407
  "PG-07": score,
8322
8408
  "PG-08": issuesCleared,
@@ -9008,6 +9094,10 @@ var EngineService = class {
9008
9094
  briefContextCache = /* @__PURE__ */ new WeakMap();
9009
9095
  /** The 15s panel poll reuses the heavy book projection until revision/damage changes. */
9010
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();
9011
9101
  briefs;
9012
9102
  now;
9013
9103
  metadataMutex = new KeyedMutex();
@@ -10539,7 +10629,7 @@ var EngineService = class {
10539
10629
  };
10540
10630
  }
10541
10631
  case "history": {
10542
- const entries = await listHistory(store, input.chapter);
10632
+ const entries = await listHistory(store, input.chapter, { withChars: true });
10543
10633
  if (input.revision !== void 0) {
10544
10634
  if (!Number.isInteger(input.revision) || input.revision < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "history revision must be a positive integer");
10545
10635
  const matching = entries.filter((entry) => entry.chapter === chapter && entry.revision === input.revision);
@@ -10883,9 +10973,16 @@ var EngineService = class {
10883
10973
  detail: snapshot.projectError ?? `novel.yaml is readable`,
10884
10974
  ...snapshot.projectError === null ? {} : { hint: "read novel_state {kind:\"novel\"} and repair it with novel_amend" }
10885
10975
  });
10976
+ const profileCount = Object.values(this.rulepack.genres).filter((genre) => genre.profile !== void 0).length;
10886
10977
  add({
10887
- id: "pipeline",
10978
+ id: "rulepack",
10888
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",
10889
10986
  title: "流水线",
10890
10987
  detail: `revision ${snapshot.pipeline.revision} · ${projectChapterProgress({
10891
10988
  activeChapter: snapshot.pipeline.chapter,
@@ -11184,12 +11281,13 @@ var EngineService = class {
11184
11281
  foreshadows: snapshot.foreshadows,
11185
11282
  relationships: snapshot.relationships,
11186
11283
  reviews: currentReviews,
11187
- activity: journal.rows.slice(-8),
11284
+ activity: journal.rows.filter((row) => row.op !== "lock_reclaimed").slice(-8),
11188
11285
  journal: journal.rows,
11189
11286
  reviewAttempts,
11190
11287
  finalChecks,
11191
11288
  targetChapters: snapshot.project.targets.total_chapters,
11192
11289
  minScore: this.config.quality.minScore,
11290
+ dimensionMax: Object.fromEntries(this.rulepack.quality.dimensions.map((dimension) => [dimension.id, dimension.max])),
11193
11291
  foreshadowDormantAfter: this.rulepack.crosschapter.foreshadow_dormant_after
11194
11292
  });
11195
11293
  if (snapshot.projectError !== null) report.warnings.push(`项目定义不可读:${snapshot.projectError}`);
@@ -12268,6 +12366,10 @@ var EngineService = class {
12268
12366
  record.archived_at = archivedAt;
12269
12367
  const outline = markOutlineArchived(snapshot.outline, record, archivedAt, snapshot.project.targets.words_per_chapter);
12270
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);
12271
12373
  const foreshadows = ledger.foreshadows;
12272
12374
  const timeline = ledger.timeline;
12273
12375
  for (const item of foreshadows.items) if (item.status !== "resolved" && chapter - item.last_touched >= this.rulepack.crosschapter.foreshadow_dormant_after) item.status = "dormant";
@@ -12468,32 +12570,24 @@ var EngineService = class {
12468
12570
  oldDocument = narrow(kind, parse(backup));
12469
12571
  } catch {}
12470
12572
  }
12471
- if (kind === "outline" && (snapshot.outlineError !== null || !snapshot.outlinePresent)) {
12573
+ if (kind === "outline") {
12472
12574
  const old = recordOf(oldDocument);
12473
12575
  const oldChapters = Array.isArray(old?.chapters) ? [...old.chapters] : [];
12474
- const known = new Set(oldChapters.map((raw) => recordOf(raw)).filter((row) => row !== null).map((row) => row.num).filter((num) => Number.isInteger(num)));
12475
- 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
+ }
12476
12581
  for (const record of snapshot.chapters.chapters) {
12477
- if (known.has(record.num)) continue;
12478
- const candidate = candidates.get(record.num);
12479
- if (candidate === void 0 || candidate.status !== "archived") continue;
12480
- let plan = null;
12481
- const planRaw = await context.store.readText(planChapterFile(record.num));
12482
- if (planRaw !== null) try {
12483
- const parsed = parsePlanYaml(planRaw, planChapterFile(record.num));
12484
- validateStoredChapterPlan(parsed, planChapterFile(record.num));
12485
- plan = parsed;
12486
- } catch {
12487
- plan = null;
12488
- }
12489
- const planCast = Array.isArray(plan?.cast) ? plan.cast.map((member) => recordOf(member)?.name ?? "").filter((name) => name !== "") : [];
12490
- const allowedTargets = [snapshot.project.targets.words_per_chapter, ...Array.isArray(plan?.target_words) ? [plan.target_words] : []];
12491
- const allowedCasts = [planCast, autoCardCast(record)];
12492
- const targetMatches = allowedTargets.some((target) => JSON.stringify(target) === JSON.stringify(candidate.target_words));
12493
- const castMatches = allowedCasts.some((cast) => JSON.stringify(cast) === JSON.stringify(candidate.cast));
12494
- if (!targetMatches || !castMatches) continue;
12495
- oldChapters.push({ ...candidate });
12496
- 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
+ };
12497
12591
  }
12498
12592
  oldDocument = {
12499
12593
  ...old ?? {},
@@ -12893,6 +12987,436 @@ var EngineService = class {
12893
12987
  return projectGates(await this.panelSnapshot(input.root));
12894
12988
  }
12895
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
+ /**
12896
13420
  * Build the read-only panel payload. The query root is accepted only when it
12897
13421
  * is registered, active or the configured root; arbitrary paths never reach
12898
13422
  * the filesystem from the panel route.
@@ -12925,9 +13449,10 @@ var EngineService = class {
12925
13449
  add(await optionFor(active, known !== void 0, known?.title, known?.created_at));
12926
13450
  }
12927
13451
  const configured = this.defaultRoot();
13452
+ const explicitConfigured = this.config.projectRoot !== "./novel" && configured !== resolve("./novel");
12928
13453
  if (!rows.has(configured)) {
12929
13454
  const option = await optionFor(configured, false);
12930
- if (option.state !== "missing" || active === configured) add(option);
13455
+ if (option.state !== "missing" || active === configured || explicitConfigured) add(option);
12931
13456
  }
12932
13457
  const selectedRoot = input.root === void 0 ? void 0 : resolve(input.root);
12933
13458
  if (selectedRoot !== void 0 && !rows.has(selectedRoot) && selectedRoot === configured) add(await optionFor(configured, false));
@@ -12991,7 +13516,7 @@ var EngineService = class {
12991
13516
  };
12992
13517
  let snapshot;
12993
13518
  try {
12994
- snapshot = await this.panelSnapshot(selection.root);
13519
+ snapshot = await this.panelSnapshot(selection.root, { includeQuality: input.lean !== true });
12995
13520
  } catch (error) {
12996
13521
  if (error instanceof EngineError) {
12997
13522
  const busy = error.code === ERROR_CODES.TXN_IN_PROGRESS;
@@ -13020,12 +13545,21 @@ var EngineService = class {
13020
13545
  nextTool: summary.next.tool,
13021
13546
  ...summary.progress === void 0 ? {} : { progress: summary.progress }
13022
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
+ }));
13023
13557
  return {
13024
13558
  projects,
13025
13559
  selection,
13026
13560
  summary,
13027
- chapters: projectChapters(snapshot),
13028
- gates: projectGates(snapshot),
13561
+ chapters,
13562
+ etag,
13029
13563
  ...resume === void 0 ? {} : { resume }
13030
13564
  };
13031
13565
  }
@@ -13054,6 +13588,7 @@ var EngineService = class {
13054
13588
  }
13055
13589
  async stats(input = {}) {
13056
13590
  const snapshot = await this.load(input.root);
13591
+ const stateErrors = this.stateErrors(snapshot);
13057
13592
  const archivedWords = snapshot.chapters.chapters.reduce((total, chapter) => total + chapter.word_count, 0);
13058
13593
  const activeIndexed = snapshot.chapters.chapters.some((chapter) => chapter.num === snapshot.pipeline.chapter);
13059
13594
  const draftWords = snapshot.text === null || activeIndexed || snapshot.pipeline.stage === "done" ? 0 : countWordMetrics(snapshot.text).total_word_count;
@@ -13079,7 +13614,9 @@ var EngineService = class {
13079
13614
  blueprintReady: bookPlanPresent(snapshot.outline.book),
13080
13615
  worldbookEntries: counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory + counts.facts,
13081
13616
  strictOutline: snapshot.outline.book.strict,
13082
- targetChapters: snapshot.project.targets.total_chapters
13617
+ targetChapters: snapshot.project.targets.total_chapters,
13618
+ state_errors: stateErrors,
13619
+ partial: stateErrors.length > 0
13083
13620
  };
13084
13621
  }
13085
13622
  registry() {
@@ -13098,6 +13635,17 @@ var EngineService = class {
13098
13635
  ...inspection.state === "ok" && !sameIdentity ? { identity_changed: true } : {}
13099
13636
  };
13100
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
+ }
13101
13649
  return {
13102
13650
  active: document.active,
13103
13651
  projects
@@ -13556,9 +14104,48 @@ var EngineService = class {
13556
14104
  }
13557
14105
  };
13558
14106
  }
13559
- async panelSnapshot(root) {
13560
- const selection = await this.resolveRoot(root);
13561
- 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;
13562
14149
  const archived = snapshot.chapters.chapters;
13563
14150
  const archivedCurrent = archived.some((chapter) => chapter.num === snapshot.pipeline.chapter);
13564
14151
  const target = snapshot.project.targets.total_chapters;
@@ -13573,19 +14160,12 @@ var EngineService = class {
13573
14160
  const archivedWords = archived.reduce((total, chapter) => total + chapter.word_count, 0);
13574
14161
  const pending = await inspectPending(this.storeFor(snapshot.root));
13575
14162
  const stateErrorCount = this.stateErrors(snapshot).length;
13576
- const journal = await readJournal(this.storeFor(snapshot.root));
13577
- const journalBlocking = await this.journalBlocking(snapshot);
13578
- const journalTail = journal.rows[journal.rows.length - 1];
13579
- const journalKey = sha256(stableJson({
13580
- rows: journal.rows.length,
13581
- tail: journalTail === void 0 ? null : [
13582
- journalTail.seq,
13583
- journalTail.at,
13584
- journalTail.status
13585
- ],
13586
- blocking: journalBlocking
13587
- }));
13588
- const panelCacheKey = `${snapshot.root}:${snapshot.pipeline.revision}:${pending.state}:${stateErrorCount}:${snapshot.outlineError ?? ""}:${snapshot.worldbookError ?? ""}:${JSON.stringify(snapshot.briefs)}:${journalKey}`;
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"}`;
13589
14169
  const cachedPanel = this.panelCache.get(snapshot.root);
13590
14170
  if (cachedPanel?.key === panelCacheKey) return cachedPanel.snapshot;
13591
14171
  const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
@@ -13634,26 +14214,31 @@ var EngineService = class {
13634
14214
  const qualityScores = scores.slice(-5);
13635
14215
  const previousScores = scores.slice(-10, -5);
13636
14216
  const store = this.storeFor(snapshot.root);
13637
- const reviewNames = new Set(await store.list(REVIEW_DIR));
13638
- const sampleChapters = /* @__PURE__ */ new Set();
13639
14217
  const currentReviews = [];
13640
- for (const record of archived) {
13641
- const name = reviewFile(record.num).slice(`${REVIEW_DIR}/`.length);
13642
- if (!reviewNames.has(name)) continue;
13643
- try {
13644
- const review = await store.readDoc(reviewFile(record.num), "review");
13645
- if (review !== null) {
13646
- sampleChapters.add(record.num);
13647
- currentReviews.push(review);
13648
- }
13649
- } 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);
13650
14237
  }
13651
- const samples = sampleChapters.size;
13652
- const archivedChapterNums = new Set(archived.map((record) => record.num));
13653
- const attempts = [...(await this.reviewAttemptsByChapter(store, journal.rows, [...archivedChapterNums], sampleChapters)).values()].reduce((total, count) => total + count, 0);
13654
14238
  const qualityTrendBasis = samples < 3 ? "insufficient_samples" : previousScores.length < 5 ? "insufficient_history" : "last_five_vs_previous_five";
13655
14239
  const quality = {
13656
- 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,
13657
14242
  scores,
13658
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,
13659
14244
  trend_basis: qualityTrendBasis,
@@ -13670,37 +14255,58 @@ var EngineService = class {
13670
14255
  hint: `第 ${violation.location.paragraph} 段`
13671
14256
  }));
13672
14257
  const legacy = await this.legacyIssues(snapshot, false);
13673
- const activity = journal.rows.slice(-5).map((row) => ({
14258
+ const activity = journal.rows.filter((row) => row.op !== "lock_reclaimed").slice(-5).map((row) => ({
13674
14259
  at: row.at,
13675
14260
  op: row.op,
13676
14261
  chapter: row.chapter,
13677
14262
  status: row.status
13678
14263
  }));
13679
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
+ }
13680
14308
  const bookData = buildPanelBookData({
13681
- report: buildBookReport({
13682
- project: snapshot.project,
13683
- outline: snapshot.outline,
13684
- outlinePresent: snapshot.outlinePresent,
13685
- outlineError: snapshot.outlineError,
13686
- worldbook: snapshot.worldbook,
13687
- worldbookPresent: snapshot.worldbookPresent,
13688
- worldbookError: snapshot.worldbookError,
13689
- world: snapshot.world,
13690
- chapters: archived,
13691
- characters: snapshot.characters,
13692
- timeline: snapshot.timeline,
13693
- foreshadows: snapshot.foreshadows,
13694
- relationships: snapshot.relationships,
13695
- reviews: currentReviews,
13696
- activity: journal.rows.slice(-8),
13697
- journal: journal.rows,
13698
- reviewAttempts: attempts,
13699
- finalChecks: Object.fromEntries(archived.filter((record) => record.final_check === "pass" || record.final_check === "fail").map((record) => [record.num, record.final_check])),
13700
- targetChapters: target,
13701
- minScore: this.config.quality.minScore,
13702
- foreshadowDormantAfter: this.rulepack.crosschapter.foreshadow_dormant_after
13703
- }),
14309
+ report: panelReport,
13704
14310
  project: snapshot.project,
13705
14311
  worldbook: snapshot.worldbook,
13706
14312
  world: snapshot.world,
@@ -13711,7 +14317,7 @@ var EngineService = class {
13711
14317
  stage: snapshot.pipeline.stage,
13712
14318
  rounds: snapshot.pipeline.rounds,
13713
14319
  draftWords,
13714
- 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,
13715
14321
  manuscriptHash: snapshot.pipeline.draft?.content_hash ?? (snapshot.text === null ? null : sha256(snapshot.text)),
13716
14322
  metrics: draftMetrics,
13717
14323
  stateErrors: this.stateErrors(snapshot).map((error) => ({
@@ -13731,7 +14337,7 @@ var EngineService = class {
13731
14337
  num: snapshot.pipeline.chapter,
13732
14338
  title: stripTitlePrefix(snapshot.plan?.title ?? "") || `第${snapshot.pipeline.chapter}章`,
13733
14339
  wordCount: draftWords,
13734
- score: snapshot.review?.total ?? 0,
14340
+ score: snapshot.review?.total ?? null,
13735
14341
  status: blocking.length > 0 ? "blocked" : "draft",
13736
14342
  details: (snapshot.pipeline.draft?.summary?.events ?? []).join(";").slice(0, 240)
13737
14343
  };
@@ -15269,6 +15875,29 @@ const machineWarnings = (machine, acknowledged = []) => machine.violations.filte
15269
15875
  message: `${violation.detail} (paragraph ${violation.location.paragraph})`
15270
15876
  }));
15271
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
+ };
15272
15901
  const legacyStableJson = (value) => {
15273
15902
  const normalize = (input) => {
15274
15903
  if (Array.isArray(input)) return input.map(normalize);
@@ -15418,6 +16047,9 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
15418
16047
  }
15419
16048
  if (before.content_hash !== after.content_hash) problems.push(`archived chapter ${String(before.num)} cannot change its content_hash`);
15420
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`);
15421
16053
  if (stableJson(before.assigned_ids ?? {}) !== stableJson(after.assigned_ids ?? {})) problems.push(`archived chapter ${String(before.num)} cannot change its assigned_ids`);
15422
16054
  for (const field of [
15423
16055
  "title",
@@ -15592,6 +16224,10 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
15592
16224
  }
15593
16225
  const indexed = new Set(snapshot.chapters.chapters.map((record) => record.num));
15594
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
+ }
15595
16231
  if (!chaptersUnreadable) {
15596
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`);
15597
16233
  }
@@ -15885,16 +16521,17 @@ function diffLines(before, after) {
15885
16521
  const arraySemanticsDescription = (semantics, description) => `[x-semantics: ${semantics}] ${description}`;
15886
16522
  // src/tools/index.ts
15887
16523
  /**
15888
- * Providers sometimes serialize a free-form `json` parameter as a JSON string
15889
- * even when the model intended an object. Accept both wire shapes; an invalid
15890
- * 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.
15891
16528
  */
15892
16529
  const asJson = (value, field) => {
15893
16530
  if (typeof value !== "string") return value;
15894
16531
  try {
15895
16532
  return JSON.parse(value);
15896
16533
  } catch {
15897
- 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`);
15898
16535
  }
15899
16536
  };
15900
16537
  /** Turn an engine failure into the message shape tools surface. */
@@ -15964,17 +16601,255 @@ const stringArray = (semantics, description) => ({
15964
16601
  items: { type: "string" },
15965
16602
  description: arraySemanticsDescription(semantics, description)
15966
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
+ });
15967
16842
  const root = {
15968
16843
  type: "string",
15969
- 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."
15970
16845
  };
15971
16846
  const initRoot = {
15972
16847
  type: "string",
15973
- 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."
15974
16849
  };
15975
16850
  const utilityRoot = {
15976
16851
  type: "string",
15977
- 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."
15978
16853
  };
15979
16854
  /**
15980
16855
  * The twenty-eight tools are thin adapters: narrow parameters, call one service
@@ -16119,11 +16994,19 @@ function createNovelTools(engine, options = {}) {
16119
16994
  },
16120
16995
  constraints: stringArray("replace", "P1 constraints; supplied list replaces the plan list."),
16121
16996
  characters: {
16122
- type: "json",
16997
+ ...arrayOf(objectOf({
16998
+ name: required(str),
16999
+ role: required(str),
17000
+ current_state: required(str)
17001
+ })),
16123
17002
  description: arraySemanticsDescription("replace", "P1 cast; supplied list replaces the cast.")
16124
17003
  },
16125
17004
  locations: {
16126
- type: "json",
17005
+ ...arrayOf(objectOf({
17006
+ name: required(str),
17007
+ layout: required(str),
17008
+ capacity: required(num$1)
17009
+ })),
16127
17010
  description: arraySemanticsDescription("replace", "P1 locations; supplied list replaces them.")
16128
17011
  },
16129
17012
  continuity_notes: stringArray("replace", "P1 continuity notes; supplied list replaces them."),
@@ -16176,8 +17059,14 @@ function createNovelTools(engine, options = {}) {
16176
17059
  description: arraySemanticsDescription("replace", "Plan [min,max] word target.")
16177
17060
  },
16178
17061
  cast: {
16179
- type: "json",
16180
- 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.")
16181
17070
  },
16182
17071
  project_root: root
16183
17072
  },
@@ -16221,9 +17110,8 @@ function createNovelTools(engine, options = {}) {
16221
17110
  required: true
16222
17111
  },
16223
17112
  summary: {
16224
- type: "json",
16225
- required: true,
16226
- 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."
16227
17115
  },
16228
17116
  project_root: root
16229
17117
  },
@@ -16247,12 +17135,12 @@ function createNovelTools(engine, options = {}) {
16247
17135
  required: true
16248
17136
  },
16249
17137
  dimensions: {
16250
- type: "json",
17138
+ ...arrayOf(dimensionSchema),
16251
17139
  required: true,
16252
17140
  description: arraySemanticsDescription("replace", "Fifteen scored dimensions.")
16253
17141
  },
16254
17142
  issues: {
16255
- type: "json",
17143
+ ...arrayOf(issueSchema),
16256
17144
  description: arraySemanticsDescription("replace", "Review issue list.")
16257
17145
  },
16258
17146
  total: {
@@ -16261,8 +17149,7 @@ function createNovelTools(engine, options = {}) {
16261
17149
  description: "150 - Σdelta, the same number the review reports."
16262
17150
  },
16263
17151
  proof: {
16264
- type: "json",
16265
- required: true,
17152
+ ...proofSchema,
16266
17153
  description: arraySemanticsDescription("replace", "Three reading-proof quotes.")
16267
17154
  },
16268
17155
  project_root: root
@@ -16289,20 +17176,24 @@ function createNovelTools(engine, options = {}) {
16289
17176
  type: "string",
16290
17177
  description: "Full revised text; mutually exclusive with patch."
16291
17178
  },
16292
- patch: {
16293
- type: "json",
16294
- description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
16295
- },
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
+ }),
16296
17187
  summary: {
16297
- type: "json",
17188
+ ...optional(summarySchema),
16298
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."
16299
17190
  },
16300
17191
  issues: {
16301
- type: "json",
17192
+ ...arrayOf(issueUpdateSchema),
16302
17193
  description: arraySemanticsDescription("replace", "Updated issue statuses and evidence for PG-08.")
16303
17194
  },
16304
17195
  proof: {
16305
- type: "json",
17196
+ ...optional(proofSchema),
16306
17197
  description: arraySemanticsDescription("replace", "Three quotes from the revised text; required unless this is a summary-only repair. PG-09 verifies them.")
16307
17198
  },
16308
17199
  project_root: root
@@ -16328,7 +17219,24 @@ function createNovelTools(engine, options = {}) {
16328
17219
  },
16329
17220
  scope: stringArray("replace", "Requested check scopes; supplied list replaces the request."),
16330
17221
  semantic: {
16331
- 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
+ })),
16332
17240
  required: true,
16333
17241
  description: arraySemanticsDescription("replace", "Exactly the five P6 semantic items, each with a verbatim evidence quote.")
16334
17242
  },
@@ -16357,10 +17265,10 @@ function createNovelTools(engine, options = {}) {
16357
17265
  required: true
16358
17266
  },
16359
17267
  state_delta: {
16360
- type: "json",
16361
- required: true
17268
+ ...stateDeltaSchema,
17269
+ description: "Five ledger sections; entry values are dynamic maps validated by the engine."
16362
17270
  },
16363
- handoff: { type: "json" },
17271
+ handoff: handoffSchema,
16364
17272
  project_root: root
16365
17273
  },
16366
17274
  output: {
@@ -16470,7 +17378,7 @@ function createNovelTools(engine, options = {}) {
16470
17378
  ]
16471
17379
  },
16472
17380
  content: {
16473
- type: "json",
17381
+ ...openObject("按 path 的完整替换文档;根必须是映射。"),
16474
17382
  required: true
16475
17383
  },
16476
17384
  reason: {
@@ -16613,7 +17521,7 @@ function createNovelTools(engine, options = {}) {
16613
17521
  description: "Require a chapter card before P2/P3."
16614
17522
  },
16615
17523
  volumes: {
16616
- type: "json",
17524
+ ...arrayOf(volumeSchema),
16617
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.")
16618
17526
  },
16619
17527
  confirm_remove: {
@@ -16643,15 +17551,25 @@ function createNovelTools(engine, options = {}) {
16643
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.",
16644
17552
  parameters: {
16645
17553
  book: {
16646
- 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
+ }),
16647
17565
  description: "Optional partial book blueprint patch."
16648
17566
  },
16649
17567
  volumes: {
16650
- type: "json",
17568
+ ...arrayOf(volumeSchema),
16651
17569
  description: arraySemanticsDescription("upsert", "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Upserts by title; omitted volumes are kept.")
16652
17570
  },
16653
17571
  chapters: {
16654
- type: "json",
17572
+ ...arrayOf(chapterCardSchema),
16655
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.")
16656
17574
  },
16657
17575
  remove: {
@@ -16718,31 +17636,31 @@ function createNovelTools(engine, options = {}) {
16718
17636
  premise: { type: "string" },
16719
17637
  setting: { type: "string" },
16720
17638
  rules: {
16721
- type: "json",
17639
+ ...arrayOf(worldbookRule),
16722
17640
  description: arraySemanticsDescription("replace", "Rules section list.")
16723
17641
  },
16724
17642
  factions: {
16725
- type: "json",
17643
+ ...arrayOf(worldbookFaction),
16726
17644
  description: arraySemanticsDescription("replace", "Factions section list.")
16727
17645
  },
16728
17646
  locations: {
16729
- type: "json",
17647
+ ...arrayOf(worldbookLocation),
16730
17648
  description: arraySemanticsDescription("replace", "Locations section list.")
16731
17649
  },
16732
17650
  characters: {
16733
- type: "json",
17651
+ ...arrayOf(worldbookCharacter),
16734
17652
  description: arraySemanticsDescription("replace", "Characters section list.")
16735
17653
  },
16736
17654
  glossary: {
16737
- type: "json",
17655
+ ...arrayOf(worldbookGlossary),
16738
17656
  description: arraySemanticsDescription("replace", "Glossary section list.")
16739
17657
  },
16740
17658
  backstory: {
16741
- type: "json",
17659
+ ...arrayOf(worldbookBackstory),
16742
17660
  description: arraySemanticsDescription("replace", "Backstory section list.")
16743
17661
  },
16744
17662
  facts: {
16745
- type: "json",
17663
+ ...arrayOf(worldbookFact),
16746
17664
  description: arraySemanticsDescription("replace", "Declared numeric canon: [{alias,value,unit,tolerance?,note?}].")
16747
17665
  },
16748
17666
  themes: stringArray("replace", "Worldbook themes; supplied list replaces the list."),
@@ -16890,6 +17808,9 @@ const NEXT_DETAILS = {
16890
17808
  novel_rollback: "回退到更早阶段重建当前章。",
16891
17809
  novel_amend: "按明确的修复理由修正一份状态文档。",
16892
17810
  novel_audit: "先查看事务日志,确认并恢复损坏的历史。",
17811
+ novel_restore: "按备份清单恢复损坏的状态文档或当前章。",
17812
+ novel_state: "查看状态文档、大纲、世界观或历史原文。",
17813
+ novel_pipeline: "按当前阶段领取下一步工具与参数。",
16893
17814
  novel_export: "导出成书或书级提案。",
16894
17815
  novel_diff: "对比当前正文与上一版或给定文本。",
16895
17816
  novel_word_count: "按引擎字数口径核对正文长度。"
@@ -17069,7 +17990,9 @@ function createCommands(engine, host = {}) {
17069
17990
  }
17070
17991
  case "stats": {
17071
17992
  const stats = await engine.stats({});
17072
- return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords}(已归档 ${stats.archivedWords} + 草稿 ${stats.draftWords}) · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
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 ? " · 部分状态不可读" : ""}`);
17073
17996
  }
17074
17997
  case "outline": {
17075
17998
  const status = await engine.getStatus({});
@@ -17099,7 +18022,7 @@ function createCommands(engine, host = {}) {
17099
18022
  case "report": {
17100
18023
  const { data } = await engine.report({});
17101
18024
  return ok([
17102
- `进度:${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}%`}`,
17103
18026
  `大纲:${data.outline.planned} 计划 / ${data.outline.archived} 归档 · 漂移 ${data.outline.drift.length} 项`,
17104
18027
  `质量:均分 ${data.quality.average} · 中位 ${data.quality.median} · 趋势 ${data.quality.trend === null ? "—" : `${data.quality.trend >= 0 ? "+" : ""}${data.quality.trend}`} · ${data.quality.samples} 章样本 / ${data.quality.attempts} 次评审`,
17105
18028
  `伏笔:未回收 ${data.foreshadows.open} · 逾期 ${data.foreshadows.overdue}`,
@@ -17276,7 +18199,9 @@ function createCommands(engine, host = {}) {
17276
18199
  if (invocation.signal.aborted) return bad("已取消");
17277
18200
  try {
17278
18201
  const stats = await engine.stats({});
17279
- return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords}(已归档 ${stats.archivedWords} + 草稿 ${stats.draftWords}) · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
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 ? " · 部分状态不可读" : ""}`);
17280
18205
  } catch (error) {
17281
18206
  return failure(error);
17282
18207
  }
@@ -17342,10 +18267,34 @@ function createSkill() {
17342
18267
  };
17343
18268
  }
17344
18269
  // src/remote/index.ts
17345
- /** Build the payload for one optional panel query root. */
17346
- 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
+ };
17347
18293
  return {
17348
- ...await engine.panelBundle(root === void 0 ? {} : { root }),
18294
+ ...await engine.panelBundle({
18295
+ ...root === void 0 ? {} : { root },
18296
+ lean
18297
+ }),
17349
18298
  schemaVersion: 6
17350
18299
  };
17351
18300
  }
@@ -17358,6 +18307,34 @@ const queryRoot = (url) => {
17358
18307
  return;
17359
18308
  }
17360
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
+ };
17361
18338
  const denyAll = () => 503;
17362
18339
  const rejectionText = {
17363
18340
  401: "unauthorized",
@@ -17384,7 +18361,24 @@ function panelHandler(engine, guard = denyAll) {
17384
18361
  return;
17385
18362
  }
17386
18363
  try {
17387
- 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
+ }
17388
18382
  const body = method === "HEAD" ? "" : JSON.stringify(payload);
17389
18383
  response.statusCode = 200;
17390
18384
  response.setHeader("content-type", "application/json; charset=utf-8");
@@ -17489,7 +18483,7 @@ const DEFAULT_RULEPACK = {
17489
18483
  },
17490
18484
  period_density: {
17491
18485
  min: 1.5,
17492
- max: 8
18486
+ max: 10
17493
18487
  },
17494
18488
  ellipsis_run: {
17495
18489
  max: 1,
@@ -17698,7 +18692,34 @@ function compileRulepack(raw) {
17698
18692
  }
17699
18693
  const genres = object(root.genres, "genres");
17700
18694
  knownKeys(genres, GENRES, "genres");
17701
- 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
+ }
17702
18723
  section("crosschapter", [
17703
18724
  "duplicate_similarity",
17704
18725
  "duplicate_advisory",
@@ -17782,7 +18803,7 @@ function compileRulepack(raw) {
17782
18803
  textList(array(conclusionFirst.causals, "lexicon.conclusion_first.causals"), "lexicon.conclusion_first.causals");
17783
18804
  for (const genre of GENRES) {
17784
18805
  const guide = object(pack.genres[genre], `genres.${genre}`);
17785
- knownKeys(guide, ["note"], `genres.${genre}`);
18806
+ knownKeys(guide, ["note", "profile"], `genres.${genre}`);
17786
18807
  if (typeof guide.note !== "string") invalid(`genres.${genre}.note must be a string`);
17787
18808
  }
17788
18809
  const nonNegative = (value, path) => {
@@ -18052,4 +19073,4 @@ function connectionFence(scope) {
18052
19073
  }
18053
19074
  if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
18054
19075
  }
18055
- export { ARCHIVE_DIGESTS_FILE, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BriefLibrary, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, HISTORY_DIR, HISTORY_KINDS, INITIAL_STAGE, LIMITS, LOCK_FILE, METADATA_LOCK_FILE, OPERATIONS, OUTLINE_DEPRECATED_FILE, OUTLINE_DIR, OUTLINE_FILE, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, TOOL_NAMES, WORLDBOOK_FILE, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapterProgress, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };
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 };