dsh-novel-write-engine 0.2.3 → 0.2.4

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
@@ -20,6 +20,8 @@ const PANEL_CHAPTER_LIMIT = 30;
20
20
  /** Project-relative locations. Every write goes through these constants. */
21
21
  const PROJECT_FILE = "novel.yaml";
22
22
  const OUTLINE_FILE = "outline/outline.yaml";
23
+ /** Last outline replaced by a normal planning write; one level is enough for repair. */
24
+ const OUTLINE_DEPRECATED_FILE = "outline/outline.deprecated.yaml";
23
25
  const WORLDBOOK_FILE = "outline/worldbook.yaml";
24
26
  const STATE_DIR = "state";
25
27
  const CHAPTERS_DIR = "chapters";
@@ -3810,7 +3812,7 @@ function buildEpub(project, chapters) {
3810
3812
  const escapeXml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3811
3813
  // src/engine/book.ts
3812
3814
  const LENIENT_TITLE_PREFIX$1 = /^第\s*\d+\s*章\s*/;
3813
- const text$1 = (value, where, max = LIMITS.maxOutlineTextFieldChars) => {
3815
+ const text$2 = (value, where, max = LIMITS.maxOutlineTextFieldChars) => {
3814
3816
  if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
3815
3817
  const trimmed = value.trim();
3816
3818
  if (Array.from(trimmed).length > max) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} is ${Array.from(trimmed).length} characters; limit is ${max}`);
@@ -3829,13 +3831,13 @@ const stringArray$1 = (value, where, maxItems = 20) => {
3829
3831
  if (value.length > maxItems) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} contains ${value.length} items; limit is ${maxItems}`);
3830
3832
  const seen = /* @__PURE__ */ new Set();
3831
3833
  return value.map((item, index) => {
3832
- const entry = text$1(item, `${where}[${index}]`, 200);
3834
+ const entry = text$2(item, `${where}[${index}]`, 200);
3833
3835
  if (seen.has(entry)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} contains duplicate "${entry}"`);
3834
3836
  seen.add(entry);
3835
3837
  return entry;
3836
3838
  });
3837
3839
  };
3838
- const positiveInteger = (value, where) => {
3840
+ const positiveInteger$1 = (value, where) => {
3839
3841
  if (!Number.isInteger(value) || value < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a positive integer`);
3840
3842
  return value;
3841
3843
  };
@@ -3845,7 +3847,7 @@ const wordPair = (value, where) => {
3845
3847
  if (!Number.isInteger(min) || !Number.isInteger(max) || min <= 0 || max < min) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be [min, max] positive integers with min <= max`);
3846
3848
  return [min, max];
3847
3849
  };
3848
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3850
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3849
3851
  /** Remove a leading 第N章 prefix; stored titles are bodies. */
3850
3852
  function stripChapterPrefix(title) {
3851
3853
  return title.replace(LENIENT_TITLE_PREFIX$1, "").trim();
@@ -3947,7 +3949,7 @@ const BOOK_PATCH_KEYS = /* @__PURE__ */ new Set([
3947
3949
  /** Apply a partial book patch; omitted fields keep their existing value. */
3948
3950
  function mergeBookPlan(existing, patch) {
3949
3951
  if (patch === void 0) return { ...existing };
3950
- if (!isRecord$1(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "book must be an object");
3952
+ if (!isRecord$2(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "book must be an object");
3951
3953
  for (const key of Object.keys(patch)) if (!BOOK_PATCH_KEYS.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `book has unknown field "${key}"`, `allowed: ${[...BOOK_PATCH_KEYS].join(", ")}`);
3952
3954
  const result = { ...existing };
3953
3955
  for (const key of [
@@ -3975,15 +3977,15 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
3975
3977
  if (!Array.isArray(input)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
3976
3978
  if (input.length > LIMITS.maxOutlineVolumes) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains ${input.length} items; limit is ${LIMITS.maxOutlineVolumes}`);
3977
3979
  const volumes = input.map((raw, index) => {
3978
- if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
3979
- const start = positiveInteger(raw.start_chapter, `volumes[${index}].start_chapter`);
3980
- const end = positiveInteger(raw.end_chapter, `volumes[${index}].end_chapter`);
3980
+ if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
3981
+ const start = positiveInteger$1(raw.start_chapter, `volumes[${index}].start_chapter`);
3982
+ const end = positiveInteger$1(raw.end_chapter, `volumes[${index}].end_chapter`);
3981
3983
  if (end < start) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] end_chapter ${end} is before start_chapter ${start}`);
3982
3984
  const status = raw.status ?? "planned";
3983
3985
  if (status !== "planned" && status !== "writing" && status !== "done") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}].status must be planned|writing|done`);
3984
3986
  return {
3985
- id: typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : `V${index + 1}`,
3986
- title: text$1(raw.title, `volumes[${index}].title`, 120),
3987
+ id: typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : "",
3988
+ title: text$2(raw.title, `volumes[${index}].title`, 120),
3987
3989
  start_chapter: start,
3988
3990
  end_chapter: end,
3989
3991
  goal: optionalText(raw.goal, `volumes[${index}].goal`),
@@ -3992,6 +3994,23 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
3992
3994
  status
3993
3995
  };
3994
3996
  });
3997
+ const usedIds = /* @__PURE__ */ new Set();
3998
+ const usedTitles = /* @__PURE__ */ new Set();
3999
+ for (const volume of volumes) {
4000
+ if (usedTitles.has(volume.title) && options.rejectDuplicateTitles !== false) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `duplicate volume title "${volume.title}"`);
4001
+ usedTitles.add(volume.title);
4002
+ if (volume.id === "") continue;
4003
+ if (usedIds.has(volume.id)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `duplicate volume id "${volume.id}"`);
4004
+ usedIds.add(volume.id);
4005
+ }
4006
+ let nextId = 1;
4007
+ for (const volume of volumes) {
4008
+ if (volume.id !== "") continue;
4009
+ while (usedIds.has(`V${nextId}`)) nextId += 1;
4010
+ volume.id = `V${nextId}`;
4011
+ usedIds.add(volume.id);
4012
+ nextId += 1;
4013
+ }
3995
4014
  volumes.sort((left, right) => left.start_chapter - right.start_chapter || left.end_chapter - right.end_chapter);
3996
4015
  const ids = /* @__PURE__ */ new Set();
3997
4016
  for (const [index, volume] of volumes.entries()) {
@@ -4075,8 +4094,8 @@ function normalizeOutlineChapters(input, options) {
4075
4094
  const archived = new Map(options.archived.map((record) => [record.num, record]));
4076
4095
  const normalized = /* @__PURE__ */ new Map();
4077
4096
  for (const [index, raw] of input.entries()) {
4078
- if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4079
- const num = positiveInteger(raw.num, `chapters[${index}].num`);
4097
+ if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4098
+ const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
4080
4099
  if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`, "raise total_chapters with novel_blueprint before planning a longer book");
4081
4100
  if (normalized.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
4082
4101
  const record = archived.get(num);
@@ -4093,9 +4112,9 @@ function normalizeOutlineChapters(input, options) {
4093
4112
  if (cast.length !== base.cast.length || cast.some((name) => !base.cast.includes(name))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} is archived; its cast cannot change`, "call novel_reopen first if the manuscript must be revised");
4094
4113
  normalized.set(num, {
4095
4114
  ...base,
4096
- goal: raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$1(raw.goal, `chapter ${num} goal`),
4097
- conflict: raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$1(raw.conflict, `chapter ${num} conflict`),
4098
- hook: raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$1(raw.hook, `chapter ${num} hook`),
4115
+ goal: raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$2(raw.goal, `chapter ${num} goal`),
4116
+ conflict: raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$2(raw.conflict, `chapter ${num} conflict`),
4117
+ hook: raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$2(raw.hook, `chapter ${num} hook`),
4099
4118
  notes: raw.notes === void 0 || raw.notes === base.notes ? base.notes : optionalText(raw.notes, `chapter ${num} notes`, LIMITS.maxOutlineNotesChars),
4100
4119
  volume: volumeForChapter(options.volumes, num, raw.volume === void 0 ? base.volume : raw.volume),
4101
4120
  status: "archived",
@@ -4114,18 +4133,19 @@ function normalizeOutlineChapters(input, options) {
4114
4133
  "情感爆发章",
4115
4134
  "纯叙事章"
4116
4135
  ].includes(raw.scene_type)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}].scene_type is invalid`);
4117
- const cast = stringArray$1(raw.cast, `chapters[${index}].cast`, 8);
4136
+ const previousCard = existing.get(num);
4137
+ const cast = raw.cast == null && previousCard?.status === "planned" ? previousCard.cast : stringArray$1(raw.cast, `chapters[${index}].cast`, 8);
4118
4138
  normalized.set(num, {
4119
4139
  num,
4120
- volume: volumeNumberFor(options.volumes, num, raw.volume),
4140
+ volume: raw.volume !== void 0 ? volumeNumberFor(options.volumes, num, raw.volume) : previousCard?.status === "planned" ? volumeForChapter(options.volumes, num, previousCard.volume) : volumeNumberFor(options.volumes, num, void 0),
4121
4141
  title,
4122
4142
  scene_type: raw.scene_type,
4123
- goal: text$1(raw.goal, `chapters[${index}].goal`),
4124
- conflict: text$1(raw.conflict, `chapters[${index}].conflict`),
4125
- hook: text$1(raw.hook, `chapters[${index}].hook`),
4143
+ goal: text$2(raw.goal, `chapters[${index}].goal`),
4144
+ conflict: text$2(raw.conflict, `chapters[${index}].conflict`),
4145
+ hook: text$2(raw.hook, `chapters[${index}].hook`),
4126
4146
  cast,
4127
4147
  target_words: wordPair(raw.target_words, `chapters[${index}].target_words`),
4128
- notes: optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
4148
+ notes: raw.notes == null && previousCard?.status === "planned" ? previousCard.notes : optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
4129
4149
  status: "planned",
4130
4150
  archived_at: null,
4131
4151
  word_count: null,
@@ -4148,7 +4168,7 @@ function normalizeOutlineChapters(input, options) {
4148
4168
  }
4149
4169
  /** Validate a document loaded from disk. Throws a fault, not an input error. */
4150
4170
  function validateOutlineDocument(value, options) {
4151
- if (!isRecord$1(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml must be a mapping");
4171
+ if (!isRecord$2(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml must be a mapping");
4152
4172
  try {
4153
4173
  if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml schema_version must be 1, got ${String(value.schema_version)}`);
4154
4174
  const allowedTopLevel = /* @__PURE__ */ new Set([
@@ -4160,7 +4180,7 @@ function validateOutlineDocument(value, options) {
4160
4180
  ]);
4161
4181
  for (const key of Object.keys(value)) if (!allowedTopLevel.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml has unknown key "${key}"`);
4162
4182
  if (typeof value.updated_at !== "string" || value.updated_at.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml updated_at must be a string");
4163
- if (!isRecord$1(value.book)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml book must be a mapping");
4183
+ if (!isRecord$2(value.book)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml book must be a mapping");
4164
4184
  const allowedBook = /* @__PURE__ */ new Set([
4165
4185
  "premise",
4166
4186
  "logline",
@@ -4175,14 +4195,17 @@ function validateOutlineDocument(value, options) {
4175
4195
  ]);
4176
4196
  for (const key of Object.keys(value.book)) if (!allowedBook.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml book has unknown key "${key}"`);
4177
4197
  const book = mergeBookPlan(emptyBookPlan(), value.book);
4178
- const volumes = normalizeVolumes(value.volumes, { totalChapters: options.totalChapters });
4198
+ const volumes = normalizeVolumes(value.volumes, {
4199
+ totalChapters: options.totalChapters,
4200
+ rejectDuplicateTitles: false
4201
+ });
4179
4202
  if (!Array.isArray(value.chapters)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapters must be an array");
4180
4203
  if (value.chapters.length > LIMITS.maxOutlineChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters contains ${value.chapters.length} items; limit is ${LIMITS.maxOutlineChapters}`);
4181
4204
  const chapters = [];
4182
4205
  const seen = /* @__PURE__ */ new Set();
4183
4206
  for (const [index, raw] of value.chapters.entries()) {
4184
- if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4185
- const num = positiveInteger(raw.num, `chapters[${index}].num`);
4207
+ if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
4208
+ const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
4186
4209
  if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`);
4187
4210
  if (seen.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
4188
4211
  seen.add(num);
@@ -4208,9 +4231,9 @@ function validateOutlineDocument(value, options) {
4208
4231
  volume: volumeNumberFor(volumes, num, raw.volume),
4209
4232
  title: chapterTitleBody(raw.title, options.titleRule, `chapters[${index}].title`),
4210
4233
  scene_type: scene,
4211
- goal: planned ? text$1(raw.goal, `chapters[${index}].goal`) : optionalText(raw.goal, `chapters[${index}].goal`),
4212
- conflict: planned ? text$1(raw.conflict, `chapters[${index}].conflict`) : optionalText(raw.conflict, `chapters[${index}].conflict`),
4213
- hook: planned ? text$1(raw.hook, `chapters[${index}].hook`) : optionalText(raw.hook, `chapters[${index}].hook`),
4234
+ goal: planned ? text$2(raw.goal, `chapters[${index}].goal`) : optionalText(raw.goal, `chapters[${index}].goal`),
4235
+ conflict: planned ? text$2(raw.conflict, `chapters[${index}].conflict`) : optionalText(raw.conflict, `chapters[${index}].conflict`),
4236
+ hook: planned ? text$2(raw.hook, `chapters[${index}].hook`) : optionalText(raw.hook, `chapters[${index}].hook`),
4214
4237
  cast: stringArray$1(raw.cast, `chapters[${index}].cast`, 8),
4215
4238
  target_words: wordPair(raw.target_words, `chapters[${index}].target_words`),
4216
4239
  notes: optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
@@ -4247,7 +4270,7 @@ function mergeWorldbook(existing, patch, now) {
4247
4270
  ...existing,
4248
4271
  updated_at: now
4249
4272
  };
4250
- if (!isRecord$1(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "worldbook must be an object");
4273
+ if (!isRecord$2(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "worldbook must be an object");
4251
4274
  const result = {
4252
4275
  ...existing,
4253
4276
  updated_at: now,
@@ -4267,21 +4290,21 @@ function mergeWorldbook(existing, patch, now) {
4267
4290
  if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} must be an array`);
4268
4291
  if (value.length > LIMITS.maxWorldbookEntries) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} contains ${value.length} items; limit is ${LIMITS.maxWorldbookEntries}`);
4269
4292
  return value.map((raw, index) => {
4270
- if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key}[${index}] must be an object`);
4293
+ if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key}[${index}] must be an object`);
4271
4294
  return raw;
4272
4295
  });
4273
4296
  };
4274
4297
  if (patch.rules !== void 0) {
4275
4298
  result.rules = records(patch.rules, "rules").map((raw, index) => ({
4276
- name: text$1(raw.name, `rules[${index}].name`, 120),
4277
- description: text$1(raw.description, `rules[${index}].description`, LIMITS.maxWorldbookTextChars)
4299
+ name: text$2(raw.name, `rules[${index}].name`, 120),
4300
+ description: text$2(raw.description, `rules[${index}].description`, LIMITS.maxWorldbookTextChars)
4278
4301
  }));
4279
4302
  uniqueNames(result.rules, "rules");
4280
4303
  }
4281
4304
  if (patch.factions !== void 0) {
4282
4305
  result.factions = records(patch.factions, "factions").map((raw, index) => ({
4283
- name: text$1(raw.name, `factions[${index}].name`, 120),
4284
- description: text$1(raw.description, `factions[${index}].description`, LIMITS.maxWorldbookTextChars),
4306
+ name: text$2(raw.name, `factions[${index}].name`, 120),
4307
+ description: text$2(raw.description, `factions[${index}].description`, LIMITS.maxWorldbookTextChars),
4285
4308
  goal: optionalText(raw.goal, `factions[${index}].goal`)
4286
4309
  }));
4287
4310
  uniqueNames(result.factions, "factions");
@@ -4291,8 +4314,8 @@ function mergeWorldbook(existing, patch, now) {
4291
4314
  const capacity = raw.capacity ?? 0;
4292
4315
  if (!Number.isInteger(capacity) || capacity < 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `locations[${index}].capacity must be a non-negative integer`);
4293
4316
  return {
4294
- name: text$1(raw.name, `locations[${index}].name`, 120),
4295
- description: text$1(raw.description, `locations[${index}].description`, LIMITS.maxWorldbookTextChars),
4317
+ name: text$2(raw.name, `locations[${index}].name`, 120),
4318
+ description: text$2(raw.description, `locations[${index}].description`, LIMITS.maxWorldbookTextChars),
4296
4319
  layout: optionalText(raw.layout, `locations[${index}].layout`),
4297
4320
  capacity
4298
4321
  };
@@ -4301,17 +4324,17 @@ function mergeWorldbook(existing, patch, now) {
4301
4324
  }
4302
4325
  if (patch.characters !== void 0) {
4303
4326
  result.characters = records(patch.characters, "characters").map((raw, index) => ({
4304
- name: text$1(raw.name, `characters[${index}].name`, 120),
4305
- role: text$1(raw.role, `characters[${index}].role`, 60),
4306
- description: text$1(raw.description, `characters[${index}].description`, LIMITS.maxWorldbookTextChars),
4327
+ name: text$2(raw.name, `characters[${index}].name`, 120),
4328
+ role: text$2(raw.role, `characters[${index}].role`, 60),
4329
+ description: text$2(raw.description, `characters[${index}].description`, LIMITS.maxWorldbookTextChars),
4307
4330
  arc: optionalText(raw.arc, `characters[${index}].arc`)
4308
4331
  }));
4309
4332
  uniqueNames(result.characters, "characters");
4310
4333
  }
4311
4334
  if (patch.glossary !== void 0) {
4312
4335
  result.glossary = records(patch.glossary, "glossary").map((raw, index) => ({
4313
- term: text$1(raw.term, `glossary[${index}].term`, 120),
4314
- definition: text$1(raw.definition, `glossary[${index}].definition`, LIMITS.maxWorldbookTextChars)
4336
+ term: text$2(raw.term, `glossary[${index}].term`, 120),
4337
+ definition: text$2(raw.definition, `glossary[${index}].definition`, LIMITS.maxWorldbookTextChars)
4315
4338
  }));
4316
4339
  const seen = /* @__PURE__ */ new Set();
4317
4340
  for (const entry of result.glossary) {
@@ -4320,8 +4343,8 @@ function mergeWorldbook(existing, patch, now) {
4320
4343
  }
4321
4344
  }
4322
4345
  if (patch.backstory !== void 0) result.backstory = records(patch.backstory, "backstory").map((raw, index) => ({
4323
- time: text$1(raw.time, `backstory[${index}].time`, 120),
4324
- event: text$1(raw.event, `backstory[${index}].event`, LIMITS.maxWorldbookTextChars)
4346
+ time: text$2(raw.time, `backstory[${index}].time`, 120),
4347
+ event: text$2(raw.event, `backstory[${index}].event`, LIMITS.maxWorldbookTextChars)
4325
4348
  }));
4326
4349
  if (patch.facts !== void 0) {
4327
4350
  result.facts = records(patch.facts, "facts").map((raw, index) => {
@@ -4330,9 +4353,9 @@ function mergeWorldbook(existing, patch, now) {
4330
4353
  const tolerance = raw.tolerance ?? 0;
4331
4354
  if (typeof tolerance !== "number" || !Number.isFinite(tolerance) || tolerance < 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts[${index}].tolerance must be a non-negative number`);
4332
4355
  return {
4333
- alias: text$1(raw.alias, `facts[${index}].alias`, 120),
4356
+ alias: text$2(raw.alias, `facts[${index}].alias`, 120),
4334
4357
  value,
4335
- unit: text$1(raw.unit, `facts[${index}].unit`, 40),
4358
+ unit: text$2(raw.unit, `facts[${index}].unit`, 40),
4336
4359
  tolerance,
4337
4360
  ...raw.note === void 0 ? {} : { note: optionalText(raw.note, `facts[${index}].note`) }
4338
4361
  };
@@ -4347,7 +4370,7 @@ function mergeWorldbook(existing, patch, now) {
4347
4370
  }
4348
4371
  /** Validate a worldbook loaded from disk. */
4349
4372
  function validateWorldbookDocument(value, now) {
4350
- if (!isRecord$1(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/worldbook.yaml must be a mapping");
4373
+ if (!isRecord$2(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/worldbook.yaml must be a mapping");
4351
4374
  try {
4352
4375
  if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/worldbook.yaml schema_version must be 1, got ${String(value.schema_version)}`);
4353
4376
  const allowedTopLevel = /* @__PURE__ */ new Set([
@@ -4758,7 +4781,7 @@ function parseOutlineData(content, format) {
4758
4781
  } catch (error) {
4759
4782
  throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} is invalid: ${error instanceof Error ? error.message : String(error)}`);
4760
4783
  }
4761
- if (!isRecord$1(parsed)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} must be a mapping`);
4784
+ if (!isRecord$2(parsed)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} must be a mapping`);
4762
4785
  if (parsed.schema_version !== void 0 && parsed.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} schema_version must be 1, got ${String(parsed.schema_version)}`);
4763
4786
  const warnings = [];
4764
4787
  const allowed = /* @__PURE__ */ new Set([
@@ -4855,6 +4878,216 @@ function renderWorldbookMarkdown(project, document) {
4855
4878
  section("前史", document.backstory.map((entry) => `- ${oneLine(entry.time)}:${oneLine(entry.event)}`));
4856
4879
  return parts.join("\n");
4857
4880
  }
4881
+ // src/engine/outline-merge.ts
4882
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4883
+ const text$1 = (value, where) => {
4884
+ if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
4885
+ return value.trim();
4886
+ };
4887
+ const positiveInteger = (value, where) => {
4888
+ if (!Number.isInteger(value) || value < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a positive integer`);
4889
+ return value;
4890
+ };
4891
+ const parseChapterNumbers = (value, field) => {
4892
+ if (value === void 0) return /* @__PURE__ */ new Set();
4893
+ if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of chapter numbers`);
4894
+ const result = /* @__PURE__ */ new Set();
4895
+ for (const [index, raw] of value.entries()) {
4896
+ const num = positiveInteger(raw, `${field}[${index}]`);
4897
+ if (result.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} contains duplicate chapter ${num}`);
4898
+ result.add(num);
4899
+ }
4900
+ return result;
4901
+ };
4902
+ const parseVolumeTitles = (value, field) => {
4903
+ if (value === void 0) return /* @__PURE__ */ new Set();
4904
+ if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of volume titles`);
4905
+ const result = /* @__PURE__ */ new Set();
4906
+ for (const [index, raw] of value.entries()) {
4907
+ const title = text$1(raw, `${field}[${index}]`);
4908
+ if (result.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} contains duplicate "${title}"`);
4909
+ result.add(title);
4910
+ }
4911
+ return result;
4912
+ };
4913
+ const submittedNumbers = (input) => {
4914
+ const result = /* @__PURE__ */ new Set();
4915
+ if (!Array.isArray(input)) return result;
4916
+ for (const raw of input) {
4917
+ if (!isRecord$1(raw) || !Number.isInteger(raw.num) || raw.num < 1) continue;
4918
+ result.add(raw.num);
4919
+ }
4920
+ return result;
4921
+ };
4922
+ const volumeByTitle = (volumes) => new Map(volumes.map((volume) => [volume.title, volume]));
4923
+ /**
4924
+ * `volumes` is an upsert keyed by title: omitted volumes survive, and removal
4925
+ * requires naming the volume in `remove_volumes`. This deliberately does not
4926
+ * accept a positional or full-list replacement.
4927
+ */
4928
+ const mergeVolumes = (options) => {
4929
+ const removed = parseVolumeTitles(options.remove_volumes, "remove_volumes");
4930
+ if (options.volumes === void 0 && removed.size === 0) return {
4931
+ volumes: [...options.existingVolumes],
4932
+ added: [],
4933
+ updated: [],
4934
+ removed: []
4935
+ };
4936
+ if (options.volumes !== void 0 && !Array.isArray(options.volumes)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
4937
+ if (options.volumes !== void 0 || removed.size > 0) {
4938
+ const existingTitles = /* @__PURE__ */ new Set();
4939
+ for (const volume of options.existingVolumes) {
4940
+ if (existingTitles.has(volume.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline has duplicate volume title "${volume.title}"`, "repair the volume titles with novel_amend before submitting volumes");
4941
+ existingTitles.add(volume.title);
4942
+ }
4943
+ }
4944
+ const byTitle = volumeByTitle(options.existingVolumes);
4945
+ const usedIds = new Set(options.existingVolumes.map((volume) => volume.id));
4946
+ let nextGeneratedId = 1;
4947
+ const generatedId = () => {
4948
+ while (usedIds.has(`V${nextGeneratedId}`)) nextGeneratedId += 1;
4949
+ const id = `V${nextGeneratedId}`;
4950
+ usedIds.add(id);
4951
+ nextGeneratedId += 1;
4952
+ return id;
4953
+ };
4954
+ const submitted = /* @__PURE__ */ new Set();
4955
+ for (const [index, raw] of (options.volumes ?? []).entries()) {
4956
+ if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
4957
+ const title = text$1(raw.title, `volumes[${index}].title`);
4958
+ if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains duplicate title "${title}"`);
4959
+ submitted.add(title);
4960
+ const base = byTitle.get(title);
4961
+ const explicitId = typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : void 0;
4962
+ const id = base?.id ?? explicitId ?? generatedId();
4963
+ if (!usedIds.has(id)) usedIds.add(id);
4964
+ byTitle.set(title, {
4965
+ ...base ?? {},
4966
+ ...raw,
4967
+ id,
4968
+ title
4969
+ });
4970
+ }
4971
+ for (const title of removed) {
4972
+ if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${title}" cannot be both submitted and removed`);
4973
+ const existing = byTitle.get(title);
4974
+ if (existing === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${title}": it is not in the plan`);
4975
+ 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");
4976
+ byTitle.delete(title);
4977
+ }
4978
+ const volumes = normalizeVolumes([...byTitle.values()], { totalChapters: options.totalChapters });
4979
+ const before = volumeByTitle(options.existingVolumes);
4980
+ const after = volumeByTitle(volumes);
4981
+ return {
4982
+ volumes,
4983
+ added: [...after.keys()].filter((title) => !before.has(title)),
4984
+ updated: [...after.keys()].filter((title) => before.has(title) && stableJson(before.get(title)) !== stableJson(after.get(title))),
4985
+ removed: [...removed]
4986
+ };
4987
+ };
4988
+ const remapVolume = (card, oldVolumes, newVolumes) => {
4989
+ if (card.volume === null || card.volume === void 0) return card;
4990
+ const previous = oldVolumes[card.volume - 1];
4991
+ if (previous === void 0) return {
4992
+ ...card,
4993
+ volume: null
4994
+ };
4995
+ const nextIndex = newVolumes.findIndex((volume) => volume.title === previous.title);
4996
+ if (nextIndex < 0) return {
4997
+ ...card,
4998
+ volume: null
4999
+ };
5000
+ const next = newVolumes[nextIndex];
5001
+ return {
5002
+ ...card,
5003
+ volume: card.num >= next.start_chapter && card.num <= next.end_chapter ? nextIndex + 1 : null
5004
+ };
5005
+ };
5006
+ const assertArchivedVolumes = (options, volumes, removedTitles) => {
5007
+ const removed = new Set(removedTitles);
5008
+ const oldByTitle = volumeByTitle(options.existingVolumes);
5009
+ const newByTitle = volumeByTitle(volumes);
5010
+ for (const record of options.archivedChapters) {
5011
+ const card = options.existing.find((entry) => entry.num === record.num);
5012
+ if (card === void 0 || card.volume === null) continue;
5013
+ const previous = options.existingVolumes[card.volume - 1];
5014
+ if (previous === void 0) continue;
5015
+ if (removed.has(previous.title) || !oldByTitle.has(previous.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${previous.title}": archived chapter ${record.num} belongs to it`, "archived cards are evidence; keep the volume or reopen the chapter first");
5016
+ const next = newByTitle.get(previous.title);
5017
+ if (next === void 0 || record.num < next.start_chapter || record.num > next.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${previous.title}" no longer covers archived chapter ${record.num}`, "keep archived chapter ranges inside their existing volume");
5018
+ }
5019
+ };
5020
+ /**
5021
+ * Merge chapter cards by num. Existing planned cards are appended back when
5022
+ * omitted; removal is name-only (`remove`), and archived cards are always
5023
+ * backfilled from the chapter index.
5024
+ */
5025
+ const mergeOutline = (options) => {
5026
+ const submitted = submittedNumbers(options.chapters);
5027
+ const removed = parseChapterNumbers(options.remove, "remove");
5028
+ const volumeChanges = mergeVolumes(options);
5029
+ assertArchivedVolumes(options, volumeChanges.volumes, volumeChanges.removed);
5030
+ const existingByNum = new Map(options.existing.map((card) => [card.num, card]));
5031
+ const archivedByNum = new Map(options.archivedChapters.map((record) => [record.num, record]));
5032
+ const archivedNums = new Set(archivedByNum.keys());
5033
+ for (const num of removed) {
5034
+ if (submitted.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} cannot be both submitted and removed`);
5035
+ if (num === options.activeChapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove the active chapter ${num} card`, "archive or move the active chapter first; the card is required while the chapter is in flight");
5036
+ if (archivedByNum.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is archived`, "archived cards are evidence; call novel_reopen first");
5037
+ if (existingByNum.get(num)?.status !== "planned") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is not a planned card`);
5038
+ }
5039
+ const normalized = normalizeOutlineChapters(options.chapters, {
5040
+ volumes: volumeChanges.volumes,
5041
+ existing: options.existing,
5042
+ archived: options.archivedChapters,
5043
+ titleRule: options.titleRule,
5044
+ totalChapters: options.totalChapters,
5045
+ defaultTargetWords: options.defaultTargetWords,
5046
+ now: options.now
5047
+ });
5048
+ const byNum = new Map(normalized.map((card) => [card.num, card]));
5049
+ const updated = /* @__PURE__ */ new Set();
5050
+ const added = /* @__PURE__ */ new Set();
5051
+ for (const card of normalized) {
5052
+ if (!submitted.has(card.num)) continue;
5053
+ const before = existingByNum.get(card.num);
5054
+ if (before === void 0) added.add(card.num);
5055
+ else if (stableJson(before) !== stableJson(card)) updated.add(card.num);
5056
+ }
5057
+ for (const card of options.existing) {
5058
+ if (submitted.has(card.num) || removed.has(card.num)) continue;
5059
+ if (card.status === "archived" && !archivedNums.has(card.num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${card.num} has an archived outline card but is not in the chapter index`, "repair the outline or reopen/archive the chapter so the card status matches the index");
5060
+ if (archivedNums.has(card.num)) {
5061
+ if (!byNum.has(card.num)) byNum.set(card.num, {
5062
+ ...card,
5063
+ status: "archived"
5064
+ });
5065
+ continue;
5066
+ }
5067
+ const remapped = remapVolume(card, options.existingVolumes, volumeChanges.volumes);
5068
+ if (byNum.has(card.num)) {
5069
+ const current = byNum.get(card.num);
5070
+ if (stableJson(current) !== stableJson(remapped)) {
5071
+ byNum.set(card.num, remapped);
5072
+ if (card.status === "planned") updated.add(card.num);
5073
+ }
5074
+ } else {
5075
+ byNum.set(card.num, remapped);
5076
+ if (card.status === "planned" && stableJson(card) !== stableJson(remapped)) updated.add(card.num);
5077
+ }
5078
+ }
5079
+ for (const num of removed) byNum.delete(num);
5080
+ return {
5081
+ chapters: [...byNum.values()].sort((left, right) => left.num - right.num),
5082
+ volumes: volumeChanges.volumes,
5083
+ added: [...added].sort((left, right) => left - right),
5084
+ updated: [...updated].sort((left, right) => left - right),
5085
+ removed: [...removed].sort((left, right) => left - right),
5086
+ volumes_added: volumeChanges.added,
5087
+ volumes_updated: volumeChanges.updated,
5088
+ volumes_removed: volumeChanges.removed
5089
+ };
5090
+ };
4858
5091
  // src/engine/state-delta.ts
4859
5092
  const SECTIONS = [
4860
5093
  "characters",
@@ -6994,7 +7227,7 @@ const outlineAlignment = (input) => {
6994
7227
  const card = outline.chapters.find((candidate) => candidate.num === input.chapter);
6995
7228
  if (card === void 0) {
6996
7229
  if (!outline.book.strict) return [];
6997
- return [block("PG-13", `chapter ${input.chapter} has no outline card while strict mode is on`, "read novel_state {kind:\"outline\"}, then submit every existing card plus this one with novel_outline (full replacement); or turn strict off with novel_blueprint")];
7230
+ return [block("PG-13", `chapter ${input.chapter} has no outline card while strict mode is on`, "read novel_state {kind:\"outline\"}, then submit only the missing card with novel_outline (upsert by num; omitted cards are kept); do not turn strict off")];
6998
7231
  }
6999
7232
  const problems = [];
7000
7233
  if (card.status === "archived") problems.push(`chapter ${input.chapter} is already archived but is still the active chapter; call novel_reopen first`);
@@ -8212,6 +8445,7 @@ var EngineService = class {
8212
8445
  "ending",
8213
8446
  "strict",
8214
8447
  "volumes",
8448
+ "confirm_remove",
8215
8449
  "root"
8216
8450
  ]);
8217
8451
  for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_blueprint argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
@@ -8228,9 +8462,11 @@ var EngineService = class {
8228
8462
  this.assertPlanningDocumentReadable(snapshot, "outline", "novel_blueprint");
8229
8463
  this.assertArchiveIndexReadable(snapshot, "novel_blueprint");
8230
8464
  const now = this.now().toISOString();
8231
- const bookPatch = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "volumes" && key !== "root"));
8465
+ const bookPatch = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "volumes" && key !== "confirm_remove" && key !== "root"));
8232
8466
  const book = mergeBookPlan(snapshot.outline.book, bookPatch);
8233
8467
  const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
8468
+ const removedVolumes = snapshot.outline.volumes.filter((volume) => !volumes.some((next) => next.title === volume.title)).map((volume) => volume.title);
8469
+ if (removedVolumes.length > 0 && input.confirm_remove !== true) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `novel_blueprint would remove volumes: ${removedVolumes.join(", ")}`, "pass confirm_remove:true to replace the volume list, or omit volumes to keep them");
8234
8470
  let chapters = snapshot.outline.chapters.map((card) => ({
8235
8471
  ...card,
8236
8472
  volume: volumeForChapter(volumes, card.num, card.volume)
@@ -8284,12 +8520,66 @@ var EngineService = class {
8284
8520
  }
8285
8521
  });
8286
8522
  }
8523
+ outlinePlanChange(snapshot, input, now) {
8524
+ const book = mergeBookPlan(snapshot.outline.book, input.book === void 0 ? void 0 : {
8525
+ ...input.book,
8526
+ ...input.strict === void 0 ? {} : { strict: input.strict }
8527
+ });
8528
+ if (input.strict !== void 0) {
8529
+ book.strict = input.strict;
8530
+ book.strict_explicit = true;
8531
+ } else if (input.book?.strict === void 0) {
8532
+ if ((input.chapters?.length ?? 0) > 0 && snapshot.outline.chapters.length === 0 && snapshot.outline.book.strict_explicit !== true) {
8533
+ book.strict = true;
8534
+ book.strict_explicit = true;
8535
+ }
8536
+ }
8537
+ const merged = mergeOutline({
8538
+ chapters: input.chapters ?? [],
8539
+ remove: input.remove,
8540
+ volumes: input.volumes,
8541
+ remove_volumes: input.remove_volumes,
8542
+ existing: snapshot.outline.chapters,
8543
+ archivedChapters: snapshot.chapters.chapters,
8544
+ existingVolumes: snapshot.outline.volumes,
8545
+ titleRule: this.rulepack.format.title,
8546
+ totalChapters: snapshot.project.targets.total_chapters,
8547
+ defaultTargetWords: snapshot.project.targets.words_per_chapter,
8548
+ now,
8549
+ activeChapter: snapshot.pipeline.chapter
8550
+ });
8551
+ return {
8552
+ outline: {
8553
+ schema_version: 1,
8554
+ updated_at: now,
8555
+ book,
8556
+ volumes: merged.volumes,
8557
+ chapters: merged.chapters
8558
+ },
8559
+ data: {
8560
+ planned: merged.chapters.filter((card) => card.status === "planned").length,
8561
+ archived: merged.chapters.filter((card) => card.status === "archived").length,
8562
+ total: merged.chapters.length,
8563
+ strict: book.strict,
8564
+ volumes: merged.volumes.length,
8565
+ added: merged.added,
8566
+ updated: merged.updated,
8567
+ removed: merged.removed,
8568
+ volumes_added: merged.volumes_added,
8569
+ volumes_updated: merged.volumes_updated,
8570
+ volumes_removed: merged.volumes_removed
8571
+ }
8572
+ };
8573
+ }
8287
8574
  async submitOutline(input) {
8288
8575
  const allowed = /* @__PURE__ */ new Set([
8289
8576
  "book",
8290
8577
  "volumes",
8291
8578
  "chapters",
8292
8579
  "strict",
8580
+ "remove",
8581
+ "remove_volumes",
8582
+ "dry_run",
8293
8583
  "root"
8294
8584
  ]);
8295
8585
  for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_outline argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
@@ -8297,6 +8587,41 @@ var EngineService = class {
8297
8587
  this.assertProjectReadable(current, "novel_outline");
8298
8588
  this.assertPlanningDocumentReadable(current, "outline", "novel_outline");
8299
8589
  this.assertArchiveIndexReadable(current, "novel_outline");
8590
+ if ((input.chapters === void 0 || Array.isArray(input.chapters) && input.chapters.length === 0) && (input.remove === void 0 || input.remove.length === 0) && (input.remove_volumes === void 0 || input.remove_volumes.length === 0) && (input.volumes === void 0 || Array.isArray(input.volumes) && input.volumes.length === 0) && (input.book === void 0 || typeof input.book === "object" && input.book !== null && Object.keys(input.book).length === 0) && input.strict === void 0 && input.dry_run !== true) return {
8591
+ data: {
8592
+ planned: current.outline.chapters.filter((card) => card.status === "planned").length,
8593
+ archived: current.outline.chapters.filter((card) => card.status === "archived").length,
8594
+ total: current.outline.chapters.length,
8595
+ strict: current.outline.book.strict,
8596
+ volumes: current.outline.volumes.length,
8597
+ added: [],
8598
+ updated: [],
8599
+ removed: [],
8600
+ volumes_added: [],
8601
+ volumes_updated: [],
8602
+ volumes_removed: []
8603
+ },
8604
+ revision: current.pipeline.revision,
8605
+ warnings: []
8606
+ };
8607
+ if (input.dry_run === true) {
8608
+ const plan = this.outlinePlanChange(current, input, this.now().toISOString());
8609
+ this.assertOutlineWritable(current, plan.outline, "novel_outline");
8610
+ return {
8611
+ data: {
8612
+ ...plan.data,
8613
+ added: [],
8614
+ updated: [],
8615
+ removed: [],
8616
+ dry_run: true,
8617
+ would_add: plan.data.added,
8618
+ would_update: plan.data.updated,
8619
+ would_remove: plan.data.removed
8620
+ },
8621
+ revision: current.pipeline.revision,
8622
+ warnings: []
8623
+ };
8624
+ }
8300
8625
  return this.mutate({
8301
8626
  root: input.root,
8302
8627
  op: "submitOutline",
@@ -8305,72 +8630,34 @@ var EngineService = class {
8305
8630
  this.assertProjectReadable(snapshot, "novel_outline");
8306
8631
  this.assertPlanningDocumentReadable(snapshot, "outline", "novel_outline");
8307
8632
  this.assertArchiveIndexReadable(snapshot, "novel_outline");
8308
- const now = this.now().toISOString();
8309
- const book = mergeBookPlan(snapshot.outline.book, input.book === void 0 ? void 0 : {
8310
- ...input.book,
8311
- ...input.strict === void 0 ? {} : { strict: input.strict }
8312
- });
8313
- if (input.strict !== void 0) {
8314
- book.strict = input.strict;
8315
- book.strict_explicit = true;
8316
- } else if (input.book?.strict === void 0) {
8317
- if (snapshot.outline.chapters.length === 0 && snapshot.outline.book.strict_explicit !== true) {
8318
- book.strict = true;
8319
- book.strict_explicit = true;
8320
- }
8321
- }
8322
- const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
8323
- const chapters = normalizeOutlineChapters(input.chapters, {
8324
- volumes,
8325
- existing: snapshot.outline.chapters,
8326
- archived: snapshot.chapters.chapters,
8327
- titleRule: this.rulepack.format.title,
8328
- totalChapters: snapshot.project.targets.total_chapters,
8329
- defaultTargetWords: snapshot.project.targets.words_per_chapter,
8330
- now
8331
- });
8332
- const outline = {
8333
- schema_version: 1,
8334
- updated_at: now,
8335
- book,
8336
- volumes,
8337
- chapters
8338
- };
8339
- this.assertActiveCardPreserved(snapshot, book.strict, chapters, "novel_outline");
8340
- this.assertOutlineWritable(snapshot, outline, "novel_outline");
8341
- const planned = chapters.filter((card) => card.status === "planned").length;
8342
- const archived = chapters.filter((card) => card.status === "archived").length;
8343
- const kept = new Set(chapters.filter((card) => card.status === "planned").map((card) => card.num));
8344
- const dropped = snapshot.outline.chapters.filter((card) => card.status === "planned" && !kept.has(card.num)).map((card) => card.num);
8633
+ const plan = this.outlinePlanChange(snapshot, input, this.now().toISOString());
8634
+ this.assertOutlineWritable(snapshot, plan.outline, "novel_outline");
8345
8635
  const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_outline", this.now(), shortDigest(stableJson(input))) });
8636
+ const files = [{
8637
+ path: OUTLINE_FILE,
8638
+ text: yaml(plan.outline)
8639
+ }, {
8640
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8641
+ text: yaml(nextPipeline)
8642
+ }];
8643
+ const prior = await context.store.readText(OUTLINE_FILE);
8644
+ if (prior !== null) files.push({
8645
+ path: OUTLINE_DEPRECATED_FILE,
8646
+ text: prior
8647
+ });
8346
8648
  return {
8347
- files: [{
8348
- path: OUTLINE_FILE,
8349
- text: yaml(outline)
8350
- }, {
8351
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8352
- text: yaml(nextPipeline)
8353
- }],
8649
+ files,
8354
8650
  backup: true,
8355
- data: {
8356
- planned,
8357
- archived,
8358
- total: chapters.length,
8359
- strict: book.strict,
8360
- volumes: volumes.length
8361
- },
8362
- warnings: dropped.length === 0 ? [] : [{
8363
- code: "OUTLINE_PLANNED_DROPPED",
8364
- message: `this replacement dropped planned cards: ${dropped.join(", ")}; resubmit every existing card when adding one`
8365
- }],
8651
+ data: plan.data,
8652
+ warnings: [],
8366
8653
  event: {
8367
8654
  name: "novel/planned",
8368
8655
  payload: {
8369
8656
  schemaVersion: 1,
8370
8657
  projectRoot: snapshot.root,
8371
8658
  kind: "outline",
8372
- chapters: chapters.length,
8373
- volumes: volumes.length
8659
+ chapters: plan.data.total,
8660
+ volumes: plan.data.volumes
8374
8661
  }
8375
8662
  }
8376
8663
  };
@@ -8737,6 +9024,53 @@ var EngineService = class {
8737
9024
  run: async (snapshot, context) => {
8738
9025
  const manifest = await readBackupManifest(context.store, input.txid);
8739
9026
  if (manifest === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} has no readable manifest`, "list backups with novel_state {kind:\"backups\"}; only marked engine backups can be restored");
9027
+ if (input.paths?.length === 1 && input.paths[0] === "outline/outline.yaml") {
9028
+ this.assertArchiveIndexReadable(snapshot, "novel_restore");
9029
+ if (input.chapter !== void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restoring outline/outline.yaml does not accept a chapter", "omit chapter when paths is exactly [outline/outline.yaml]");
9030
+ if (!manifest.files.includes("outline/outline.yaml")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${OUTLINE_FILE}`, "choose a backup created by novel_outline, novel_blueprint or a planning amendment");
9031
+ const text = await context.store.readText(`backups/${input.txid}/${OUTLINE_FILE}`);
9032
+ if (text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${OUTLINE_FILE}`, "the file was created by that transaction, so it has no pre-image to restore");
9033
+ let document;
9034
+ try {
9035
+ document = validateOutlineDocument(parse(text), {
9036
+ titleRule: this.rulepack.format.title,
9037
+ totalChapters: snapshot.project.targets.total_chapters
9038
+ });
9039
+ } catch (error) {
9040
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid outline: ${errorMessage(error)}`, "restore a different backup or repair the file manually");
9041
+ }
9042
+ const restoredCards = new Map(document.chapters.map((card) => [card.num, card]));
9043
+ const archivedNums = new Set(snapshot.chapters.chapters.map((record) => record.num));
9044
+ for (const record of snapshot.chapters.chapters) {
9045
+ const restoredCard = restoredCards.get(record.num);
9046
+ if (restoredCard?.status !== "archived") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} would make archived chapter ${record.num} ${restoredCard === void 0 ? "missing its card" : "a planned card"}`, "call novel_reopen before restoring a pre-archive outline, or choose an outline backup that already contains the archived card");
9047
+ }
9048
+ for (const card of document.chapters) if (card.status === "archived" && !archivedNums.has(card.num)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} would restore an archived card for chapter ${card.num}, but that chapter is not archived`, "choose an outline backup that matches the current chapter index, or reopen/archive the chapter first");
9049
+ const files = [{
9050
+ path: OUTLINE_FILE,
9051
+ text
9052
+ }];
9053
+ const prior = await context.store.readText(OUTLINE_FILE);
9054
+ if (prior !== null) files.push({
9055
+ path: OUTLINE_DEPRECATED_FILE,
9056
+ text: prior
9057
+ });
9058
+ const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_restore", this.now(), shortDigest(input.txid)) });
9059
+ files.push({
9060
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
9061
+ text: yaml(nextPipeline)
9062
+ });
9063
+ return {
9064
+ files,
9065
+ backup: true,
9066
+ data: {
9067
+ txid: input.txid,
9068
+ chapter: snapshot.pipeline.chapter,
9069
+ restored: [OUTLINE_FILE]
9070
+ },
9071
+ warnings: []
9072
+ };
9073
+ }
8740
9074
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
8741
9075
  if (snapshot.chapters.chapters.some((record) => record.num === chapter)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is archived; restoring it behind the index would break its content hash`, "call novel_reopen for the chapter, then restore the backup");
8742
9076
  if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not the active chapter ${snapshot.pipeline.chapter}; restore only the active chapter`, "switch the active chapter first, or restore files from the active chapter");
@@ -11963,17 +12297,15 @@ var EngineService = class {
11963
12297
  throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is unreadable; ${operation} would overwrite the unreadable planning document`, `read ${path} with novel_state and repair it with novel_amend before ${operation}`);
11964
12298
  }
11965
12299
  /**
11966
- * A strict outline cannot silently lose the active chapter's card while the
11967
- * chapter is still being written. Otherwise a late replacement turns a
11968
- * strict project non-strict in effect and archive would auto-create the card
11969
- * the author was required to provide.
12300
+ * An import is a full replacement, unlike novel_outline's keyed upsert. It
12301
+ * must not silently lose the active card while strict mode still requires it.
11970
12302
  */
11971
12303
  assertActiveCardPreserved(snapshot, strict, chapters, operation) {
11972
12304
  if (!strict) return;
11973
12305
  const chapter = snapshot.pipeline.chapter;
11974
12306
  if (!snapshot.outline.chapters.some((card) => card.num === chapter)) return;
11975
12307
  if (chapters.some((card) => card.num === chapter)) return;
11976
- throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot remove the active chapter ${chapter} card while strict mode is on`, "include the active card in the replacement, or turn strict mode off with novel_blueprint {\"strict\":false}");
12308
+ throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot remove the active chapter ${chapter} card while strict mode is on`, "include the active card in the imported outline; import replaces the whole plan and will not turn strict mode off for you");
11977
12309
  }
11978
12310
  /**
11979
12311
  * The outline is a derived progress view and the PG-13 input. Every write
@@ -14164,6 +14496,8 @@ function diffLines(before, after) {
14164
14496
  hunks
14165
14497
  };
14166
14498
  }
14499
+ // src/tools/semantics.ts
14500
+ const arraySemanticsDescription = (semantics, description) => `[x-semantics: ${semantics}] ${description}`;
14167
14501
  // src/tools/index.ts
14168
14502
  /**
14169
14503
  * Providers sometimes serialize a free-form `json` parameter as a JSON string
@@ -14239,10 +14573,11 @@ const jsonOutput = {
14239
14573
  schema: { type: "json" },
14240
14574
  render: (_args, raw) => withReplay(text(payload(raw)), raw)
14241
14575
  };
14242
- const stringArray = {
14576
+ const stringArray = (semantics, description) => ({
14243
14577
  type: "array",
14244
- items: { type: "string" }
14245
- };
14578
+ items: { type: "string" },
14579
+ description: arraySemanticsDescription(semantics, description)
14580
+ });
14246
14581
  const root = {
14247
14582
  type: "string",
14248
14583
  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."
@@ -14284,7 +14619,8 @@ function createNovelTools(engine, options = {}) {
14284
14619
  description: { type: "string" },
14285
14620
  words_per_chapter: {
14286
14621
  type: "array",
14287
- items: { type: "integer" }
14622
+ items: { type: "integer" },
14623
+ description: arraySemanticsDescription("replace", "Fixed [min,max] word target for chapters.")
14288
14624
  },
14289
14625
  total_chapters: { type: "integer" },
14290
14626
  premise: {
@@ -14292,11 +14628,11 @@ function createNovelTools(engine, options = {}) {
14292
14628
  description: "Optional one-sentence premise for the book plan."
14293
14629
  },
14294
14630
  logline: { type: "string" },
14295
- themes: stringArray,
14631
+ themes: stringArray("replace", "Book themes; supplied list replaces the theme list."),
14296
14632
  tone: { type: "string" },
14297
14633
  pov: { type: "string" },
14298
14634
  audience: { type: "string" },
14299
- selling_points: stringArray,
14635
+ selling_points: stringArray("replace", "Selling points; supplied list replaces the list."),
14300
14636
  ending: { type: "string" },
14301
14637
  strict_outline: { type: "boolean" },
14302
14638
  project_root: initRoot
@@ -14393,10 +14729,16 @@ function createNovelTools(engine, options = {}) {
14393
14729
  type: "string",
14394
14730
  required: true
14395
14731
  },
14396
- constraints: stringArray,
14397
- characters: { type: "json" },
14398
- locations: { type: "json" },
14399
- continuity_notes: stringArray,
14732
+ constraints: stringArray("replace", "P1 constraints; supplied list replaces the plan list."),
14733
+ characters: {
14734
+ type: "json",
14735
+ description: arraySemanticsDescription("replace", "P1 cast; supplied list replaces the cast.")
14736
+ },
14737
+ locations: {
14738
+ type: "json",
14739
+ description: arraySemanticsDescription("replace", "P1 locations; supplied list replaces them.")
14740
+ },
14741
+ continuity_notes: stringArray("replace", "P1 continuity notes; supplied list replaces them."),
14400
14742
  project_root: root
14401
14743
  },
14402
14744
  output: jsonOutput,
@@ -14428,7 +14770,7 @@ function createNovelTools(engine, options = {}) {
14428
14770
  required: true
14429
14771
  },
14430
14772
  beats: {
14431
- ...stringArray,
14773
+ ...stringArray("replace", "Ordered plan beats; supplied list replaces the beats."),
14432
14774
  required: true
14433
14775
  },
14434
14776
  opening: {
@@ -14442,11 +14784,12 @@ function createNovelTools(engine, options = {}) {
14442
14784
  target_words: {
14443
14785
  type: "array",
14444
14786
  items: { type: "integer" },
14445
- required: true
14787
+ required: true,
14788
+ description: arraySemanticsDescription("replace", "Plan [min,max] word target.")
14446
14789
  },
14447
14790
  cast: {
14448
14791
  type: "json",
14449
- description: "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."
14792
+ 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.")
14450
14793
  },
14451
14794
  project_root: root
14452
14795
  },
@@ -14517,9 +14860,13 @@ function createNovelTools(engine, options = {}) {
14517
14860
  },
14518
14861
  dimensions: {
14519
14862
  type: "json",
14520
- required: true
14863
+ required: true,
14864
+ description: arraySemanticsDescription("replace", "Fifteen scored dimensions.")
14865
+ },
14866
+ issues: {
14867
+ type: "json",
14868
+ description: arraySemanticsDescription("replace", "Review issue list.")
14521
14869
  },
14522
- issues: { type: "json" },
14523
14870
  total: {
14524
14871
  type: "integer",
14525
14872
  required: true,
@@ -14527,7 +14874,8 @@ function createNovelTools(engine, options = {}) {
14527
14874
  },
14528
14875
  proof: {
14529
14876
  type: "json",
14530
- required: true
14877
+ required: true,
14878
+ description: arraySemanticsDescription("replace", "Three reading-proof quotes.")
14531
14879
  },
14532
14880
  project_root: root
14533
14881
  },
@@ -14560,12 +14908,12 @@ function createNovelTools(engine, options = {}) {
14560
14908
  summary: { type: "json" },
14561
14909
  issues: {
14562
14910
  type: "json",
14563
- description: "Updated issue statuses and evidence for PG-08."
14911
+ description: arraySemanticsDescription("replace", "Updated issue statuses and evidence for PG-08.")
14564
14912
  },
14565
14913
  proof: {
14566
14914
  type: "json",
14567
14915
  required: true,
14568
- description: "Three quotes from the revised text; PG-09 verifies them."
14916
+ description: arraySemanticsDescription("replace", "Three quotes from the revised text; PG-09 verifies them.")
14569
14917
  },
14570
14918
  project_root: root
14571
14919
  },
@@ -14588,16 +14936,16 @@ function createNovelTools(engine, options = {}) {
14588
14936
  type: "integer",
14589
14937
  required: true
14590
14938
  },
14591
- scope: stringArray,
14939
+ scope: stringArray("replace", "Requested check scopes; supplied list replaces the request."),
14592
14940
  semantic: {
14593
14941
  type: "json",
14594
14942
  required: true,
14595
- description: "Exactly the five P6 semantic items, each with a verbatim evidence quote."
14943
+ description: arraySemanticsDescription("replace", "Exactly the five P6 semantic items, each with a verbatim evidence quote.")
14596
14944
  },
14597
14945
  acknowledge: {
14598
14946
  type: "array",
14599
14947
  items: { type: "string" },
14600
- description: "Advisory codes read and accepted; blockers cannot be acknowledged."
14948
+ description: arraySemanticsDescription("replace", "Advisory codes read and accepted; blockers cannot be acknowledged.")
14601
14949
  },
14602
14950
  project_root: root
14603
14951
  },
@@ -14696,7 +15044,7 @@ function createNovelTools(engine, options = {}) {
14696
15044
  chapter_range: {
14697
15045
  type: "array",
14698
15046
  items: { type: "integer" },
14699
- description: "For kind=chapters: inclusive [start, end] range."
15047
+ description: arraySemanticsDescription("replace", "For kind=chapters: inclusive [start, end] range.")
14700
15048
  },
14701
15049
  project_root: root
14702
15050
  },
@@ -14864,11 +15212,11 @@ function createNovelTools(engine, options = {}) {
14864
15212
  parameters: {
14865
15213
  premise: { type: "string" },
14866
15214
  logline: { type: "string" },
14867
- themes: stringArray,
15215
+ themes: stringArray("replace", "Book themes; supplied list replaces the theme list."),
14868
15216
  tone: { type: "string" },
14869
15217
  pov: { type: "string" },
14870
15218
  audience: { type: "string" },
14871
- selling_points: stringArray,
15219
+ selling_points: stringArray("replace", "Selling points; supplied list replaces the list."),
14872
15220
  ending: { type: "string" },
14873
15221
  strict: {
14874
15222
  type: "boolean",
@@ -14876,7 +15224,11 @@ function createNovelTools(engine, options = {}) {
14876
15224
  },
14877
15225
  volumes: {
14878
15226
  type: "json",
14879
- description: "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Replaces the current volumes."
15227
+ 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.")
15228
+ },
15229
+ confirm_remove: {
15230
+ type: "boolean",
15231
+ description: "Explicitly confirm a volume-list replacement that drops existing volumes."
14880
15232
  },
14881
15233
  project_root: root
14882
15234
  },
@@ -14892,12 +15244,13 @@ function createNovelTools(engine, options = {}) {
14892
15244
  ending: args.ending,
14893
15245
  strict: args.strict,
14894
15246
  volumes: asJson(args.volumes, "volumes"),
15247
+ confirm_remove: args.confirm_remove,
14895
15248
  root: args.project_root
14896
15249
  }))
14897
15250
  }),
14898
15251
  defineTool({
14899
15252
  name: "novel_outline",
14900
- description: "Write volume/chapter cards. Archived cards are preserved and cannot be rewritten. 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.",
15253
+ 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.",
14901
15254
  parameters: {
14902
15255
  book: {
14903
15256
  type: "json",
@@ -14905,12 +15258,25 @@ function createNovelTools(engine, options = {}) {
14905
15258
  },
14906
15259
  volumes: {
14907
15260
  type: "json",
14908
- description: "Optional full volume list; omitted keeps current volumes."
15261
+ description: arraySemanticsDescription("upsert", "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Upserts by title; omitted volumes are kept.")
14909
15262
  },
14910
15263
  chapters: {
14911
15264
  type: "json",
14912
- required: true,
14913
- description: "Chapter cards: [{num,title,scene_type,goal,conflict,hook,cast?,target_words:[min,max],volume?,notes?}]."
15265
+ 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.")
15266
+ },
15267
+ remove: {
15268
+ type: "array",
15269
+ items: { type: "integer" },
15270
+ description: arraySemanticsDescription("remove", "Explicit chapter numbers to delete.")
15271
+ },
15272
+ remove_volumes: {
15273
+ type: "array",
15274
+ items: { type: "string" },
15275
+ description: arraySemanticsDescription("remove", "Explicit volume titles to delete.")
15276
+ },
15277
+ dry_run: {
15278
+ type: "boolean",
15279
+ description: "Preview changes without writing or journaling."
14914
15280
  },
14915
15281
  strict: { type: "boolean" },
14916
15282
  project_root: root
@@ -14920,6 +15286,9 @@ function createNovelTools(engine, options = {}) {
14920
15286
  book: asJson(args.book, "book"),
14921
15287
  volumes: asJson(args.volumes, "volumes"),
14922
15288
  chapters: asJson(args.chapters, "chapters"),
15289
+ remove: args.remove,
15290
+ remove_volumes: args.remove_volumes,
15291
+ dry_run: args.dry_run,
14923
15292
  strict: args.strict,
14924
15293
  root: args.project_root
14925
15294
  }))
@@ -14958,17 +15327,35 @@ function createNovelTools(engine, options = {}) {
14958
15327
  parameters: {
14959
15328
  premise: { type: "string" },
14960
15329
  setting: { type: "string" },
14961
- rules: { type: "json" },
14962
- factions: { type: "json" },
14963
- locations: { type: "json" },
14964
- characters: { type: "json" },
14965
- glossary: { type: "json" },
14966
- backstory: { type: "json" },
15330
+ rules: {
15331
+ type: "json",
15332
+ description: arraySemanticsDescription("replace", "Rules section list.")
15333
+ },
15334
+ factions: {
15335
+ type: "json",
15336
+ description: arraySemanticsDescription("replace", "Factions section list.")
15337
+ },
15338
+ locations: {
15339
+ type: "json",
15340
+ description: arraySemanticsDescription("replace", "Locations section list.")
15341
+ },
15342
+ characters: {
15343
+ type: "json",
15344
+ description: arraySemanticsDescription("replace", "Characters section list.")
15345
+ },
15346
+ glossary: {
15347
+ type: "json",
15348
+ description: arraySemanticsDescription("replace", "Glossary section list.")
15349
+ },
15350
+ backstory: {
15351
+ type: "json",
15352
+ description: arraySemanticsDescription("replace", "Backstory section list.")
15353
+ },
14967
15354
  facts: {
14968
15355
  type: "json",
14969
- description: "Declared numeric canon: [{alias,value,unit,tolerance?,note?}]."
15356
+ description: arraySemanticsDescription("replace", "Declared numeric canon: [{alias,value,unit,tolerance?,note?}].")
14970
15357
  },
14971
- themes: stringArray,
15358
+ themes: stringArray("replace", "Worldbook themes; supplied list replaces the list."),
14972
15359
  project_root: root
14973
15360
  },
14974
15361
  output: jsonOutput,
@@ -15060,7 +15447,7 @@ function createNovelTools(engine, options = {}) {
15060
15447
  }),
15061
15448
  defineTool({
15062
15449
  name: "novel_restore",
15063
- description: "Restore an active chapter file from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
15450
+ description: "Restore an active chapter file, or outline/outline.yaml, from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
15064
15451
  parameters: {
15065
15452
  txid: {
15066
15453
  type: "string",
@@ -15070,7 +15457,7 @@ function createNovelTools(engine, options = {}) {
15070
15457
  paths: {
15071
15458
  type: "array",
15072
15459
  items: { type: "string" },
15073
- description: "Project-relative chapter files; omitted restores every restorable chapter file that the backup manifest records."
15460
+ description: arraySemanticsDescription("replace", "Project-relative chapter files, or exactly [outline/outline.yaml]; omitted restores every restorable chapter file the manifest records.")
15074
15461
  },
15075
15462
  project_root: root
15076
15463
  },
@@ -16153,4 +16540,4 @@ function connectionFence(scope) {
16153
16540
  }
16154
16541
  if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
16155
16542
  }
16156
- 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_DIR, OUTLINE_FILE, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, TOOL_NAMES, WORLDBOOK_FILE, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };
16543
+ export { ARCHIVE_DIGESTS_FILE, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BriefLibrary, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, HISTORY_DIR, HISTORY_KINDS, INITIAL_STAGE, LIMITS, LOCK_FILE, METADATA_LOCK_FILE, OPERATIONS, OUTLINE_DEPRECATED_FILE, OUTLINE_DIR, OUTLINE_FILE, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, TOOL_NAMES, WORLDBOOK_FILE, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };