dsh-novel-write-engine 0.1.0 → 0.1.1

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
@@ -350,7 +350,7 @@ function applyExclusions(dimensions, exclusions) {
350
350
  const canonical = (location) => {
351
351
  if (typeof location !== "string") return "";
352
352
  const paragraph = /第\s*(\d+)\s*段/.exec(location);
353
- return paragraph === null ? location.trim() : `paragraph:${paragraph[1]}`;
353
+ return paragraph === null ? location.trim() : `paragraph:${Number(paragraph[1])}`;
354
354
  };
355
355
  const deductionsOf = (dimension) => {
356
356
  if (typeof dimension !== "object" || dimension === null) return [];
@@ -648,27 +648,28 @@ function nextStage(stage, op, openBlockers) {
648
648
  function availableOps(stage) {
649
649
  return TRANSITIONS.filter((transition) => transition.from === stage).map((transition) => transition.op);
650
650
  }
651
- /** Whether the review/revise retry budget is spent. */
652
- function roundsExhausted(rounds) {
651
+ /** Whether one retry budget (or, without a counter, any budget) is spent. */
652
+ function roundsExhausted(rounds, counter) {
653
+ if (counter !== void 0) return rounds[counter] >= LIMITS.maxRounds;
653
654
  return rounds.review >= LIMITS.maxRounds || rounds.revise >= LIMITS.maxRounds || rounds.check >= LIMITS.maxRounds;
654
655
  }
655
656
  /** The operation a stage expects next, used by `advise` and the status `next` block. */
656
657
  const STAGE_NEXT = {
657
658
  idle: {
658
659
  tool: "novel_plan_world",
659
- reason: "call novel_brief P1, then submit the world plan"
660
+ reason: "submit the world plan"
660
661
  },
661
662
  P2: {
662
663
  tool: "novel_plan_chapter",
663
- reason: "call novel_brief P2, then submit the chapter plan"
664
+ reason: "submit the chapter plan"
664
665
  },
665
666
  P3: {
666
667
  tool: "novel_write_draft",
667
- reason: "call novel_brief P3, then submit the draft"
668
+ reason: "submit the draft"
668
669
  },
669
670
  P4: {
670
671
  tool: "novel_review",
671
- reason: "call novel_brief P4, then submit the scored review"
672
+ reason: "submit the scored review"
672
673
  },
673
674
  P5: {
674
675
  tool: "novel_revise",
@@ -676,11 +677,11 @@ const STAGE_NEXT = {
676
677
  },
677
678
  P6: {
678
679
  tool: "novel_check",
679
- reason: "run the consistency check, then archive"
680
+ reason: "run the consistency check"
680
681
  },
681
682
  ready_to_archive: {
682
- tool: "novel_brief",
683
- reason: "all gates pass; call novel_brief P8, then archive the chapter"
683
+ tool: "novel_archive",
684
+ reason: "all gates pass; archive the chapter with its state_delta"
684
685
  },
685
686
  done: {
686
687
  tool: "novel_plan_world",
@@ -1063,7 +1064,23 @@ const duplicateParagraph = (ctx) => {
1063
1064
  const previous = ctx.evidence.previousTexts.length > 0 ? ctx.evidence.previousTexts : ctx.evidence.previousText === null ? [] : [ctx.evidence.previousText];
1064
1065
  if (previous.length === 0) return [];
1065
1066
  const minChars = ctx.rulepack.crosschapter.duplicate_min_chars;
1066
- const longParagraphs = (text) => splitParagraphs(text).filter((paragraph) => Array.from(paragraph.replace(/\s+/g, "")).length >= minChars).join("\n\n");
1067
+ const longParagraphs = (text) => {
1068
+ const units = [];
1069
+ let buffer = "";
1070
+ for (const paragraph of splitParagraphs(text)) {
1071
+ buffer += paragraph;
1072
+ if (Array.from(buffer.replace(/\s+/g, "")).length >= minChars) {
1073
+ units.push(buffer);
1074
+ buffer = "";
1075
+ }
1076
+ }
1077
+ if (buffer !== "") {
1078
+ if (units.length === 0) {
1079
+ if (Array.from(buffer.replace(/\s+/g, "")).length >= minChars) units.push(buffer);
1080
+ } else units[units.length - 1] += buffer;
1081
+ }
1082
+ return units.join("\n\n");
1083
+ };
1067
1084
  const best = maxParagraphSimilarity(longParagraphs(ctx.text), previous.map((text) => longParagraphs(text)).filter((text) => text !== ""));
1068
1085
  if (best < ctx.rulepack.crosschapter.duplicate_advisory) return [];
1069
1086
  const severity = best >= ctx.rulepack.crosschapter.duplicate_similarity ? "blocker" : "advisory";
@@ -1340,7 +1357,7 @@ function resolveInside(root, relativePath) {
1340
1357
  const absoluteRoot = resolve(root);
1341
1358
  const absolute = resolve(absoluteRoot, relativePath);
1342
1359
  const inside = relative(absoluteRoot, absolute);
1343
- if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) throw new EngineFaultError(ERROR_CODES.PATH_ESCAPE, `refusing path "${relativePath}": escapes project root`);
1360
+ if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) throw new EngineFaultError(ERROR_CODES.PATH_ESCAPE, `refusing path "${relativePath}": escapes project root`);
1344
1361
  return absolute;
1345
1362
  }
1346
1363
  // src/store/migrate.ts
@@ -1415,6 +1432,7 @@ const SCHEMAS = {
1415
1432
  rounds: req("object"),
1416
1433
  briefs: req("object"),
1417
1434
  blocking: req("array"),
1435
+ review_digest: f("string"),
1418
1436
  last_action: f("any")
1419
1437
  },
1420
1438
  chapters: { chapters: req("array") },
@@ -1726,10 +1744,11 @@ function narrow(kind, value) {
1726
1744
  if (typeof version !== "number" || !Number.isInteger(version)) return invalid$1(kind, "missing schema_version");
1727
1745
  if (version > 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_TOO_NEW, `refusing ${kind}: schema_version ${version} is newer than supported 1`, "upgrade the plugin; the file was left untouched");
1728
1746
  if (version !== 1) invalid$1(kind, `unsupported schema_version ${version}`);
1747
+ if (!Object.hasOwn(SCHEMAS, kind)) invalid$1(kind, `unknown document kind "${String(kind)}"`);
1729
1748
  const schema = SCHEMAS[kind];
1730
1749
  for (const key of Object.keys(record)) {
1731
1750
  if (key === "schema_version") continue;
1732
- if (!(key in schema)) invalid$1(kind, `unknown key "${key}"`);
1751
+ if (!Object.hasOwn(schema, key)) invalid$1(kind, `unknown key "${key}"`);
1733
1752
  }
1734
1753
  for (const [key, field] of Object.entries(schema)) {
1735
1754
  const fieldValue = record[key];
@@ -1836,7 +1855,12 @@ var FsStore = class {
1836
1855
  async readDoc(relativePath, kind) {
1837
1856
  const text = await this.readText(relativePath);
1838
1857
  if (text === null) return null;
1839
- const parsed = parse(text);
1858
+ let parsed;
1859
+ try {
1860
+ parsed = parse(text);
1861
+ } catch (error) {
1862
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${relativePath} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix the YAML syntax; the engine will not rewrite a document it cannot parse", { cause: error });
1863
+ }
1840
1864
  return narrow(kind, (typeof parsed === "object" && parsed !== null && typeof parsed.schema_version === "number" ? parsed.schema_version : 1) < 1 ? migrateDocument(parsed, MIGRATIONS, 1) : parsed);
1841
1865
  }
1842
1866
  /** Write text atomically: temp file, fsync, rename, directory fsync. */
@@ -2080,6 +2104,9 @@ async function recoverPending(store) {
2080
2104
  /** Throw before a write when the journal or the last commit is inconsistent. */
2081
2105
  async function assertJournalConsistent(store) {
2082
2106
  await recoverPending(store);
2107
+ const { records } = await readJournal(store);
2108
+ const committed = new Set(records.filter((record) => record.status === "committed" && record.op !== "lock_reclaimed").map((record) => record.txid));
2109
+ for (const entry of await store.list(STAGING_DIR)) if (committed.has(entry)) await store.removeTree(joinRel(STAGING_DIR, entry)).catch(() => void 0);
2083
2110
  const { malformed, mismatches } = await reconcileJournal(store);
2084
2111
  assertJournalHealthy(malformed);
2085
2112
  if (mismatches.length > 0) {
@@ -2113,6 +2140,8 @@ var KeyedMutex = class {
2113
2140
  return this.tails.size;
2114
2141
  }
2115
2142
  };
2143
+ /** Nonces this process currently holds; same-pid locks not in this set are stale. */
2144
+ const liveNonces = /* @__PURE__ */ new Set();
2116
2145
  const isLockRecord = (value) => typeof value === "object" && value !== null && typeof value.pid === "number" && typeof value.startedAt === "string" && (value.nonce === void 0 || typeof value.nonce === "string");
2117
2146
  const processAlive = (pid) => {
2118
2147
  if (pid === process.pid) return true;
@@ -2155,6 +2184,7 @@ var FileLock = class {
2155
2184
  }
2156
2185
  await handle.close();
2157
2186
  this.held = record;
2187
+ if (record.nonce !== void 0) liveNonces.add(record.nonce);
2158
2188
  return;
2159
2189
  } catch (error) {
2160
2190
  if (error.code !== "EEXIST") throw error;
@@ -2168,7 +2198,8 @@ var FileLock = class {
2168
2198
  const record = await this.read();
2169
2199
  const held = this.held;
2170
2200
  this.held = null;
2171
- if (record !== null && held !== null && record.pid === held.pid && record.nonce === held.nonce) await promises.rm(this.absolutePath, { force: true });
2201
+ if (held?.nonce !== void 0) liveNonces.delete(held.nonce);
2202
+ if (record !== null && held !== null && record.pid === held.pid && record.nonce === held.nonce) await promises.rm(this.absolutePath, { force: true }).catch(() => void 0);
2172
2203
  }
2173
2204
  /** Read the current lock record, or null when absent/held by a foreign process. */
2174
2205
  async read() {
@@ -2199,30 +2230,25 @@ var FileLock = class {
2199
2230
  }
2200
2231
  if (record === null) {
2201
2232
  if (Date.now() - mtimeMs <= this.staleMs) return false;
2202
- } else {
2203
- const age = Date.now() - Date.parse(record.startedAt);
2204
- if (!(!Number.isFinite(age) || !processAlive(record.pid))) return false;
2205
- }
2233
+ } else if (record.pid === process.pid) {
2234
+ if (record.nonce !== void 0 && liveNonces.has(record.nonce)) return false;
2235
+ } else if (processAlive(record.pid)) return false;
2206
2236
  const claimPath = `${this.absolutePath}.reclaim-${randomUUID()}`;
2207
2237
  try {
2208
- await promises.rename(this.absolutePath, claimPath);
2238
+ await promises.link(this.absolutePath, claimPath);
2209
2239
  } catch (error) {
2210
2240
  if (error.code === "ENOENT") return true;
2211
2241
  throw error;
2212
2242
  }
2213
- let moved = null;
2214
- moved = await this.readAt(claimPath);
2243
+ const moved = await this.readAt(claimPath);
2215
2244
  const claimMtime = await promises.stat(claimPath).then((info) => info.mtimeMs).catch(() => Date.now());
2216
2245
  if (!(record === null ? moved === null && Date.now() - claimMtime > this.staleMs : moved !== null && moved.pid === record.pid && moved.startedAt === record.startedAt && moved.nonce === record.nonce)) {
2217
- try {
2218
- await promises.link(claimPath, this.absolutePath);
2219
- await promises.rm(claimPath, { force: true });
2220
- } catch {
2221
- await promises.rm(claimPath, { force: true }).catch(() => void 0);
2222
- }
2246
+ await promises.rm(claimPath, { force: true }).catch(() => void 0);
2223
2247
  return false;
2224
2248
  }
2225
- await promises.rm(claimPath, { force: true });
2249
+ const current = await this.readAt(this.absolutePath);
2250
+ if (record === null ? current === null : current !== null && current.pid === record.pid && current.startedAt === record.startedAt && current.nonce === record.nonce) await promises.rm(this.absolutePath, { force: true }).catch(() => void 0);
2251
+ await promises.rm(claimPath, { force: true }).catch(() => void 0);
2226
2252
  await this.onReclaim?.(record);
2227
2253
  return true;
2228
2254
  }
@@ -2294,6 +2320,12 @@ async function restoreTargets(store, renamed, before) {
2294
2320
  * that terminal row).
2295
2321
  */
2296
2322
  async function runTransaction(store, input) {
2323
+ const staged = /* @__PURE__ */ new Set();
2324
+ for (const file of input.files) {
2325
+ if (staged.has(file.path)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `transaction ${input.txid} stages ${file.path} more than once`, "each target may appear once per transaction; merge the writes before staging");
2326
+ if (file.text !== void 0 && file.bytes !== void 0) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `transaction ${input.txid} stages both text and bytes for ${file.path}`, "choose one representation per staged file");
2327
+ staged.add(file.path);
2328
+ }
2297
2329
  const staging = stagingDir(input.txid);
2298
2330
  const expected = /* @__PURE__ */ new Map();
2299
2331
  const before = /* @__PURE__ */ new Map();
@@ -2416,8 +2448,13 @@ var ProjectRegistry = class {
2416
2448
  async read() {
2417
2449
  const text = await this.store.readText(REGISTRY_FILE);
2418
2450
  if (text === null) return empty();
2419
- const raw = parse(text);
2420
- if (typeof raw !== "object" || raw === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not an object`);
2451
+ let raw;
2452
+ try {
2453
+ raw = parse(text);
2454
+ } catch (error) {
2455
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix or delete the registry file; projects on disk are unaffected", { cause: error });
2456
+ }
2457
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not an object`);
2421
2458
  const record = raw;
2422
2459
  if (record.schema_version !== 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} has an unsupported schema_version`);
2423
2460
  return {
@@ -2786,11 +2823,7 @@ var BriefLibrary = class {
2786
2823
  };
2787
2824
  }
2788
2825
  };
2789
- const hash = (text) => {
2790
- let value = 0;
2791
- for (let index = 0; index < text.length; index += 1) value = Math.imul(31, value) + text.charCodeAt(index) | 0;
2792
- return `fnv:${(value >>> 0).toString(16)}`;
2793
- };
2826
+ const hash = (text) => `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
2794
2827
  // src/engine/context.ts
2795
2828
  const compactSummary = (row) => ({
2796
2829
  num: row.num,
@@ -2825,7 +2858,8 @@ function briefContext(input) {
2825
2858
  chapter: input.chapter,
2826
2859
  stage: input.pipeline.stage,
2827
2860
  scene_distribution: sceneDistribution(input.chapters),
2828
- previous_ending: previousEnding
2861
+ previous_ending: previousEnding,
2862
+ ...input.plan_error === void 0 || input.plan_error === null ? {} : { plan_error: input.plan_error }
2829
2863
  };
2830
2864
  switch (input.stage) {
2831
2865
  case "P1": return {
@@ -2911,6 +2945,15 @@ const ACTIONS = [
2911
2945
  "no_change"
2912
2946
  ];
2913
2947
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2948
+ /** Define an own property even for names such as `__proto__` or `constructor`. */
2949
+ const setOwn = (record, key, value) => {
2950
+ Object.defineProperty(record, key, {
2951
+ value,
2952
+ enumerable: true,
2953
+ configurable: true,
2954
+ writable: true
2955
+ });
2956
+ };
2914
2957
  function fail$1(detail) {
2915
2958
  throw new EngineInputError(ERROR_CODES.DELTA_UNVERIFIED, `state_delta is incomplete: ${detail}`, "declare every entity the chapter touched as created, updated, or no_change with a reason");
2916
2959
  }
@@ -2961,7 +3004,7 @@ function applyStateDelta(input) {
2961
3004
  const foreshadows = structuredClone(input.foreshadows);
2962
3005
  const timeline = structuredClone(input.timeline);
2963
3006
  const relationships = structuredClone(input.relationships);
2964
- const assigned = {};
3007
+ const assigned = Object.create(null);
2965
3008
  const appeared = [...new Set(summary.characters_appeared)];
2966
3009
  const byName = new Map(characters.characters.map((character) => [character.name, character]));
2967
3010
  const narrator = input.narrator ?? "";
@@ -2973,7 +3016,7 @@ function applyStateDelta(input) {
2973
3016
  };
2974
3017
  for (const name of appeared) {
2975
3018
  if (!appearsInManuscript(name)) fail$1(`character "${name}" is listed in characters_appeared but does not appear in the manuscript`);
2976
- if (delta.characters[name] === void 0) fail$1(`character "${name}" appeared in the chapter but is not declared in characters`);
3019
+ if (!Object.hasOwn(delta.characters, name)) fail$1(`character "${name}" appeared in the chapter but is not declared in characters`);
2977
3020
  }
2978
3021
  for (const [name, raw] of Object.entries(delta.characters)) {
2979
3022
  const declared = entry(raw, `characters.${name}`);
@@ -3020,7 +3063,7 @@ function applyStateDelta(input) {
3020
3063
  }
3021
3064
  for (const change of summary.resource_changes) {
3022
3065
  if (!isRecord(change) || typeof change.character !== "string" || change.character.trim() === "" || typeof change.resource !== "string" || change.resource.trim() === "" || !Number.isInteger(change.delta) || typeof change.reason !== "string" || change.reason.trim() === "") fail$1("every resource_changes entry must declare character/resource/delta/reason");
3023
- if (delta.characters[change.character] === void 0) fail$1(`resource change for "${change.character}" has no character declaration`);
3066
+ if (!Object.hasOwn(delta.characters, change.character)) fail$1(`resource change for "${change.character}" has no character declaration`);
3024
3067
  if (delta.characters[change.character].action === "no_change") fail$1(`resource change for "${change.character}" conflicts with a no_change declaration`);
3025
3068
  const character = byName.get(change.character);
3026
3069
  if (character === void 0) fail$1(`resource change names unknown character "${change.character}"`);
@@ -3067,8 +3110,8 @@ function applyStateDelta(input) {
3067
3110
  ["artifact", summary.new_elements.artifacts]
3068
3111
  ]) for (const name of names) {
3069
3112
  if (!input.text.includes(name)) fail$1(`summary lists new ${kind} "${name}" but the manuscript never mentions it`);
3113
+ if (!Object.hasOwn(delta.world, name)) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
3070
3114
  const declared = delta.world[name];
3071
- if (declared === void 0) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
3072
3115
  if (declared.action !== "created") fail$1(`world.${name} is new in this chapter; its action must be created`);
3073
3116
  if (declared.kind !== void 0 && declared.kind !== kind) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${kind}`);
3074
3117
  }
@@ -3086,9 +3129,19 @@ function applyStateDelta(input) {
3086
3129
  }
3087
3130
  const existing = findWorld(name);
3088
3131
  if (declared.action === "created") {
3132
+ if (name.trim() === "") fail$1("a created world entity has an empty name");
3089
3133
  if (existing !== null) fail$1(`world entity "${name}" already exists; use updated or no_change`);
3134
+ if (!input.text.includes(name)) fail$1(`world entity "${name}" is created but does not appear in the manuscript`);
3090
3135
  const kind = declared.kind ?? expected;
3091
3136
  if (kind === null) fail$1(`world.${name} must declare kind (location|ability|artifact|faction|rule)`);
3137
+ if (![
3138
+ "location",
3139
+ "ability",
3140
+ "artifact",
3141
+ "faction",
3142
+ "rule"
3143
+ ].includes(kind)) fail$1(`world.${name} has unknown kind "${String(kind)}"`);
3144
+ if (expected === null && (kind === "location" || kind === "ability" || kind === "artifact")) fail$1(`world.${name} is created but is not listed in summary.new_elements`);
3092
3145
  if (kind === "location") world.locations.push({
3093
3146
  name,
3094
3147
  first_appearance: chapter,
@@ -3099,10 +3152,10 @@ function applyStateDelta(input) {
3099
3152
  changes: []
3100
3153
  });
3101
3154
  else world[kind === "ability" ? "abilities" : kind === "artifact" ? "artifacts" : kind === "faction" ? "factions" : "rules"].push({
3155
+ ...declared.detail ?? {},
3102
3156
  name,
3103
3157
  first_appearance: chapter,
3104
- description: declared.description ?? "",
3105
- ...declared.detail ?? {}
3158
+ description: declared.description ?? ""
3106
3159
  });
3107
3160
  continue;
3108
3161
  }
@@ -3124,35 +3177,45 @@ function applyStateDelta(input) {
3124
3177
  }
3125
3178
  }
3126
3179
  }
3127
- for (const name of appeared) if (locks.characters[name] === void 0) {
3180
+ for (const name of appeared) if (!Object.hasOwn(locks.characters, name)) {
3128
3181
  const character = byName.get(name);
3129
- locks.characters[name] = {
3182
+ setOwn(locks.characters, name, {
3130
3183
  first_appearance: character.first_appearance,
3131
3184
  locked_at: chapter,
3132
3185
  appearance: appearanceOf(input.text, name, name === narratorName ? narratorAliases : [])
3133
- };
3186
+ });
3134
3187
  }
3135
- for (const location of world.locations) if (locks.locations[location.name] === void 0) locks.locations[location.name] = {
3188
+ for (const location of world.locations) if (!Object.hasOwn(locks.locations, location.name)) setOwn(locks.locations, location.name, {
3136
3189
  first_appearance: location.first_appearance,
3137
3190
  locked_at: chapter,
3138
3191
  layout: location.layout,
3139
3192
  capacity: location.capacity
3140
- };
3193
+ });
3141
3194
  locks.locked_at_chapter = Math.max(locks.locked_at_chapter, chapter);
3142
3195
  const byId = new Map(foreshadows.items.map((item) => [item.id, item]));
3143
3196
  const plantedKeys = /* @__PURE__ */ new Set();
3144
3197
  for (const plant of summary.foreshadows.planted) {
3145
3198
  if (plant.key.trim() === "") fail$1("a planted foreshadow has an empty key");
3199
+ if (byId.has(plant.key)) fail$1(`foreshadow key "${plant.key}" collides with an existing id`);
3146
3200
  if (plantedKeys.has(plant.key)) fail$1(`foreshadow key "${plant.key}" is planted twice`);
3147
3201
  plantedKeys.add(plant.key);
3202
+ if (!Object.hasOwn(delta.foreshadows, plant.key)) fail$1(`foreshadows.${plant.key} is not declared`);
3148
3203
  const declared = entry(delta.foreshadows[plant.key], `foreshadows.${plant.key}`);
3149
3204
  requireReason(declared, `foreshadows.${plant.key}`);
3150
3205
  if (declared.action !== "created") fail$1(`foreshadows.${plant.key} is planted; its action must be created`);
3206
+ if (typeof plant.title !== "string" || plant.title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty summary title`);
3207
+ if (!input.text.includes(plant.title)) fail$1(`planted foreshadow summary title "${plant.title}" does not appear in the manuscript`);
3208
+ if (declared.title !== void 0 && typeof declared.title !== "string") fail$1(`foreshadows.${plant.key}.title must be a string`);
3209
+ const title = declared.title ?? plant.title;
3210
+ if (title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty title`);
3211
+ if (!input.text.includes(title)) fail$1(`planted foreshadow "${title}" does not appear in the manuscript`);
3151
3212
  const allocated = allocateIds("FS", foreshadows.next_seq, 1);
3213
+ const id = allocated.ids[0];
3214
+ if (byId.has(id)) fail$1(`foreshadows.next_seq is behind the ledger; ${id} already exists`);
3152
3215
  foreshadows.next_seq = allocated.nextSeq;
3153
3216
  const item = {
3154
- id: allocated.ids[0],
3155
- title: declared.title ?? plant.title,
3217
+ id,
3218
+ title,
3156
3219
  planted: chapter,
3157
3220
  last_touched: chapter,
3158
3221
  status: "planted",
@@ -3165,12 +3228,14 @@ function applyStateDelta(input) {
3165
3228
  assigned[plant.key] = item.id;
3166
3229
  }
3167
3230
  for (const advance of summary.foreshadows.advanced) {
3231
+ if (!Object.hasOwn(delta.foreshadows, advance.key)) fail$1(`foreshadows.${advance.key} is not declared`);
3168
3232
  const declared = entry(delta.foreshadows[advance.key], `foreshadows.${advance.key}`);
3169
3233
  requireReason(declared, `foreshadows.${advance.key}`);
3170
3234
  if (declared.action !== "updated") fail$1(`foreshadows.${advance.key} is advanced; its action must be updated`);
3171
3235
  const id = assigned[advance.key] ?? advance.key;
3172
3236
  const item = byId.get(id);
3173
3237
  if (item === void 0) fail$1(`foreshadow "${advance.key}" does not exist; no fallback is allowed`);
3238
+ if (item.status === "resolved") fail$1(`foreshadow "${advance.key}" is already resolved`);
3174
3239
  item.last_touched = chapter;
3175
3240
  item.status = "advanced";
3176
3241
  item.advance_log.push({
@@ -3181,12 +3246,14 @@ function applyStateDelta(input) {
3181
3246
  assigned[advance.key] = item.id;
3182
3247
  }
3183
3248
  for (const key of summary.foreshadows.resolved) {
3249
+ if (!Object.hasOwn(delta.foreshadows, key)) fail$1(`foreshadows.${key} is not declared`);
3184
3250
  const declared = entry(delta.foreshadows[key], `foreshadows.${key}`);
3185
3251
  requireReason(declared, `foreshadows.${key}`);
3186
3252
  if (declared.action !== "updated") fail$1(`foreshadows.${key} is resolved; its action must be updated`);
3187
3253
  const id = assigned[key] ?? key;
3188
3254
  const item = byId.get(id);
3189
3255
  if (item === void 0) fail$1(`foreshadow "${key}" does not exist; no fallback is allowed`);
3256
+ if (item.status === "resolved") fail$1(`foreshadow "${key}" is already resolved`);
3190
3257
  item.status = "resolved";
3191
3258
  item.last_touched = chapter;
3192
3259
  item.resolved_at = chapter;
@@ -3218,8 +3285,9 @@ function applyStateDelta(input) {
3218
3285
  const declared = entry(delta.timeline[key], `timeline.${key}`);
3219
3286
  if (declared.action !== "created") fail$1(`timeline.${key} is a new event; its action must be created`);
3220
3287
  const allocated = allocateIds("TE", timeline.next_seq, 1);
3221
- timeline.next_seq = allocated.nextSeq;
3222
3288
  const id = allocated.ids[0];
3289
+ if (timeline.items.some((item) => item.id === id)) fail$1(`timeline.next_seq is behind the ledger; ${id} already exists`);
3290
+ timeline.next_seq = allocated.nextSeq;
3223
3291
  timeline.items.push({
3224
3292
  id,
3225
3293
  chapter,
@@ -3246,8 +3314,9 @@ function applyStateDelta(input) {
3246
3314
  if (existing !== void 0) fail$1(`relationship ${pairKey(a, b)} already exists`);
3247
3315
  if (typeof declared.type !== "string" || declared.type.trim() === "") fail$1(`relationships.${key} is created but has no type`);
3248
3316
  const allocated = allocateIds("RL", relationships.next_seq, 1);
3249
- relationships.next_seq = allocated.nextSeq;
3250
3317
  const id = allocated.ids[0];
3318
+ if (relationships.items.some((item) => item.id === id)) fail$1(`relationships.next_seq is behind the ledger; ${id} already exists`);
3319
+ relationships.next_seq = allocated.nextSeq;
3251
3320
  relationships.items.push({
3252
3321
  id,
3253
3322
  a,
@@ -3301,6 +3370,23 @@ function evidenceQuote(value) {
3301
3370
  candidate = candidate.replace(/^[「『“"']/, "").replace(/[」』”"']$/, "").trim();
3302
3371
  return candidate;
3303
3372
  }
3373
+ /** Sentence spans, including the terminating punctuation, for evidence de-duplication. */
3374
+ function sentenceSpans(text) {
3375
+ const spans = [];
3376
+ let start = 0;
3377
+ for (let index = 0; index < text.length; index += 1) if ("。!?!?".includes(text[index])) {
3378
+ spans.push({
3379
+ start,
3380
+ end: index + 1
3381
+ });
3382
+ start = index + 1;
3383
+ }
3384
+ if (start < text.length) spans.push({
3385
+ start,
3386
+ end: text.length
3387
+ });
3388
+ return spans;
3389
+ }
3304
3390
  /**
3305
3391
  * Validate the model-supplied L2 semantic items. Evidence must be a real
3306
3392
  * substring of the manuscript; a failed verdict is itself a blocking finding.
@@ -3309,6 +3395,10 @@ function evidenceQuote(value) {
3309
3395
  function evaluateSemantic(text, items) {
3310
3396
  const blocking = [];
3311
3397
  const seen = /* @__PURE__ */ new Set();
3398
+ const seenQuotes = /* @__PURE__ */ new Set();
3399
+ const spans = [];
3400
+ const sentences = sentenceSpans(text);
3401
+ const usedSentences = /* @__PURE__ */ new Set();
3312
3402
  for (const [index, item] of items.entries()) {
3313
3403
  const where = `semantic[${index}]`;
3314
3404
  if (typeof item !== "object" || item === null) {
@@ -3353,11 +3443,11 @@ function evaluateSemantic(text, items) {
3353
3443
  continue;
3354
3444
  }
3355
3445
  const quote = evidenceQuote(item.evidence);
3356
- if (Array.from(quote).length < 4) {
3446
+ if (Array.from(quote).length < 8) {
3357
3447
  blocking.push({
3358
3448
  code: "SEMANTIC_EVIDENCE_INVALID",
3359
3449
  scope: "check",
3360
- detail: `${item.id} quotes fewer than 4 characters`
3450
+ detail: `${item.id} quotes fewer than 8 characters`
3361
3451
  });
3362
3452
  continue;
3363
3453
  }
@@ -3369,6 +3459,40 @@ function evaluateSemantic(text, items) {
3369
3459
  });
3370
3460
  continue;
3371
3461
  }
3462
+ if (seenQuotes.has(quote)) {
3463
+ blocking.push({
3464
+ code: "SEMANTIC_EVIDENCE_INVALID",
3465
+ scope: "check",
3466
+ detail: `${item.id} repeats another semantic item's quote; each check needs distinct evidence`
3467
+ });
3468
+ continue;
3469
+ }
3470
+ let start = text.indexOf(quote);
3471
+ let sentence = -1;
3472
+ while (start !== -1) {
3473
+ const end = start + quote.length;
3474
+ const overlaps = spans.some((span) => start < span.end && span.start < end);
3475
+ const sentenceIndex = sentences.findIndex((candidate) => start >= candidate.start && end <= candidate.end);
3476
+ if (!overlaps && sentenceIndex !== -1 && !usedSentences.has(sentenceIndex)) {
3477
+ sentence = sentenceIndex;
3478
+ break;
3479
+ }
3480
+ start = text.indexOf(quote, start + 1);
3481
+ }
3482
+ if (start === -1 || sentence === -1) {
3483
+ blocking.push({
3484
+ code: "SEMANTIC_EVIDENCE_INVALID",
3485
+ scope: "check",
3486
+ detail: `${item.id} cites overlapping text or the same sentence as another check; each check needs a distinct passage`
3487
+ });
3488
+ continue;
3489
+ }
3490
+ spans.push({
3491
+ start,
3492
+ end: start + quote.length
3493
+ });
3494
+ usedSentences.add(sentence);
3495
+ seenQuotes.add(quote);
3372
3496
  if (item.verdict === "fail") blocking.push({
3373
3497
  code: "SEMANTIC_CHECK_FAILED",
3374
3498
  scope: "check",
@@ -3501,6 +3625,7 @@ const summarySchema = (input) => {
3501
3625
  if (!isText(summary.opening)) problems.push("opening must be text");
3502
3626
  else {
3503
3627
  const opening = summary.opening;
3628
+ if (Array.from(opening).length < LIMITS.minQuoteChars) problems.push(`opening must be at least ${LIMITS.minQuoteChars} characters`);
3504
3629
  if (!splitParagraphs(input.text).slice(0, 2).some((paragraph) => paragraph.includes(opening))) problems.push("opening must be quoted from the first two paragraphs");
3505
3630
  }
3506
3631
  const continuity = summary.continuity;
@@ -3537,6 +3662,7 @@ const score = (input, options) => {
3537
3662
  if (review === null) return [block("PG-07", "no review document supplied")];
3538
3663
  if (!Array.isArray(review.dimensions)) return [block("PG-07", "review dimensions must be an array")];
3539
3664
  const problems = reviewArithmeticProblems(review.dimensions, review.total);
3665
+ if (applyExclusions(review.dimensions, options.rulepack.exclusions).dropped > 0) problems.push("review contains mutually exclusive or duplicate deductions; resubmit it through novel_review");
3540
3666
  if (review.content_hash !== sha256(input.text)) problems.push("review content_hash does not match the current manuscript");
3541
3667
  const paragraphCount = splitParagraphs(input.text).length;
3542
3668
  for (const dimension of review.dimensions) {
@@ -3616,13 +3742,16 @@ const readingProof = (input) => {
3616
3742
  ending
3617
3743
  ]) if (problem !== null) problems.push(problem);
3618
3744
  const paragraphs = splitParagraphs(input.text).length;
3619
- if (input.proof.opening.paragraph > 2) problems.push("the opening quote must come from the first two paragraphs");
3620
- if (input.proof.ending.paragraph < Math.max(1, paragraphs - 1)) problems.push("the ending quote must come from the last two paragraphs");
3621
3745
  const actuals = [
3622
3746
  input.proof.opening,
3623
3747
  input.proof.middle,
3624
3748
  input.proof.ending
3625
3749
  ].map((reference) => actualParagraph(input.text, reference));
3750
+ if (actuals[0] > 2) problems.push("the opening quote must come from the first two paragraphs");
3751
+ const middleMin = paragraphs <= 4 ? 2 : 3;
3752
+ const middleMax = paragraphs <= 4 ? Math.max(2, paragraphs - 1) : paragraphs - 2;
3753
+ if (actuals[1] > 0 && (actuals[1] < middleMin || actuals[1] > middleMax)) problems.push("the middle quote must come from the body of the chapter, not its first or last edges");
3754
+ if (actuals[2] > 0 && actuals[2] < Math.max(1, paragraphs - 1)) problems.push("the ending quote must come from the last two paragraphs");
3626
3755
  if (new Set(actuals.filter((index) => index > 0)).size < 3) problems.push("the three quotes must come from three distinct paragraphs");
3627
3756
  return problems.length === 0 ? [] : [block("PG-09", problems.join("; "), "quote at least 12 characters from the real paragraph you read")];
3628
3757
  };
@@ -3662,8 +3791,9 @@ const consistency = (input, options) => {
3662
3791
  /** PG-11: the review/revise budget is enforced by the engine, not self-reported. */
3663
3792
  const rounds = (input) => {
3664
3793
  if (!(input.operation === "submitReview" || input.operation === "submitRevision" || input.operation === "runCheck")) return [];
3665
- if (!roundsExhausted(input.rounds)) return [];
3666
- return [block("PG-11", `round limits reached (review ${input.rounds.review}, revise ${input.rounds.revise}, check ${input.rounds.check})`, "call novel_rollback to restart the chapter; the manuscript is kept for reference")];
3794
+ const counter = input.operation === "submitReview" ? "review" : input.operation === "submitRevision" ? "revise" : "check";
3795
+ if (!roundsExhausted(input.rounds, counter)) return [];
3796
+ return [block("PG-11", `round limits reached for ${counter} (review ${input.rounds.review}, revise ${input.rounds.revise}, check ${input.rounds.check})`, "call novel_rollback to restart the chapter; the manuscript is kept for reference")];
3667
3797
  };
3668
3798
  /** PG-12: the archive transaction's preconditions, checked before staging. */
3669
3799
  const archiveAtomic = (input) => {
@@ -3753,6 +3883,7 @@ function applicableGates(input) {
3753
3883
  "PG-06",
3754
3884
  "PG-07",
3755
3885
  "PG-08",
3886
+ "PG-09",
3756
3887
  "PG-10",
3757
3888
  "PG-12"
3758
3889
  ];
@@ -3818,23 +3949,6 @@ function projectChapter(record) {
3818
3949
  details: record.summary.events.join(";").slice(0, 240)
3819
3950
  };
3820
3951
  }
3821
- /** Status report used by the tool and by commands; one function, three callers. */
3822
- function projectStatus(snapshot, next) {
3823
- return {
3824
- project: {
3825
- title: snapshot.title,
3826
- genre: snapshot.genre,
3827
- platform: snapshot.platform,
3828
- root: snapshot.root
3829
- },
3830
- chapter: snapshot.chapter,
3831
- stage: snapshot.stage,
3832
- rounds: { ...snapshot.rounds },
3833
- blocking: snapshot.blocking,
3834
- metrics: null,
3835
- next
3836
- };
3837
- }
3838
3952
  function projectSummary(snapshot) {
3839
3953
  return {
3840
3954
  schemaVersion: 1,
@@ -4013,6 +4127,20 @@ var EngineService = class {
4013
4127
  /** Compiled on first use so `apply()` performs no IO. */
4014
4128
  rulepackSource;
4015
4129
  compiled = null;
4130
+ disposed = false;
4131
+ /**
4132
+ * Release in-process caches on host teardown. The engine owns no file
4133
+ * handles, so this is deliberately synchronous and idempotent; it prevents a
4134
+ * disposed instance from keeping project stores and replay results alive.
4135
+ */
4136
+ dispose() {
4137
+ if (this.disposed) return;
4138
+ this.disposed = true;
4139
+ this.stores.clear();
4140
+ this.replay.clear();
4141
+ this.lastRoot = null;
4142
+ this.compiled = null;
4143
+ }
4016
4144
  get rulepack() {
4017
4145
  this.compiled ??= this.rulepackSource();
4018
4146
  return this.compiled;
@@ -4119,6 +4247,7 @@ var EngineService = class {
4119
4247
  });
4120
4248
  }
4121
4249
  async getStatus(input = {}) {
4250
+ if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
4122
4251
  const snapshot = await this.load(input.root);
4123
4252
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
4124
4253
  const metrics = await this.metricsFor(snapshot, chapter);
@@ -4132,9 +4261,10 @@ var EngineService = class {
4132
4261
  root: snapshot.root
4133
4262
  },
4134
4263
  chapter,
4264
+ active_chapter: snapshot.pipeline.chapter,
4135
4265
  stage: snapshot.pipeline.stage,
4136
4266
  rounds: { ...snapshot.pipeline.rounds },
4137
- blocking: snapshot.pipeline.blocking,
4267
+ blocking: this.effectiveBlocking(snapshot),
4138
4268
  metrics,
4139
4269
  next
4140
4270
  },
@@ -4148,6 +4278,7 @@ var EngineService = class {
4148
4278
  revision: 1,
4149
4279
  warnings: []
4150
4280
  };
4281
+ if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
4151
4282
  const snapshot = await this.load(input.root);
4152
4283
  return {
4153
4284
  data: countWordMetrics((input.chapter === void 0 ? snapshot.text : await this.readChapter(snapshot, input.chapter)) ?? ""),
@@ -4156,6 +4287,7 @@ var EngineService = class {
4156
4287
  };
4157
4288
  }
4158
4289
  async queryState(input) {
4290
+ if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
4159
4291
  const snapshot = await this.load(input.root);
4160
4292
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
4161
4293
  const store = this.storeFor(snapshot.root);
@@ -4206,12 +4338,12 @@ var EngineService = class {
4206
4338
  warnings: []
4207
4339
  };
4208
4340
  case "review": return {
4209
- data: snapshot.review,
4341
+ data: chapter === snapshot.pipeline.chapter ? snapshot.review : await store.readDoc(reviewFile(chapter), "review"),
4210
4342
  revision: snapshot.pipeline.revision,
4211
4343
  warnings: []
4212
4344
  };
4213
4345
  case "check": return {
4214
- data: snapshot.check,
4346
+ data: chapter === snapshot.pipeline.chapter ? snapshot.check : await store.readDoc(checkFile(chapter), "check"),
4215
4347
  revision: snapshot.pipeline.revision,
4216
4348
  warnings: []
4217
4349
  };
@@ -4223,7 +4355,7 @@ var EngineService = class {
4223
4355
  case "planWorld": {
4224
4356
  const raw = await store.readText(planWorldFile(chapter));
4225
4357
  return {
4226
- data: raw === null ? null : parseYamlLoose(raw),
4358
+ data: raw === null ? null : parsePlanYaml(raw, planWorldFile(chapter)),
4227
4359
  revision: snapshot.pipeline.revision,
4228
4360
  warnings: []
4229
4361
  };
@@ -4231,7 +4363,7 @@ var EngineService = class {
4231
4363
  case "planChapter": {
4232
4364
  const raw = await store.readText(planChapterFile(chapter));
4233
4365
  return {
4234
- data: raw === null ? null : parseYamlLoose(raw),
4366
+ data: raw === null ? null : parsePlanYaml(raw, planChapterFile(chapter)),
4235
4367
  revision: snapshot.pipeline.revision,
4236
4368
  warnings: []
4237
4369
  };
@@ -4249,59 +4381,62 @@ var EngineService = class {
4249
4381
  tool: next.tool,
4250
4382
  args: next.args,
4251
4383
  reason: next.reason,
4252
- blocking: snapshot.pipeline.blocking
4384
+ blocking: this.effectiveBlocking(snapshot)
4253
4385
  },
4254
4386
  revision: snapshot.pipeline.revision,
4255
4387
  warnings: []
4256
4388
  };
4257
4389
  }
4258
4390
  async getBrief(input) {
4259
- const snapshot = await this.load(input.root);
4260
- const chapter = input.chapter ?? snapshot.pipeline.chapter;
4261
- const context = this.briefContextFor(input.stage, snapshot, chapter);
4262
- const digest = this.briefDigest(input.stage, snapshot, context);
4263
- const variables = this.briefVariables(snapshot, chapter);
4264
- const rendered = this.briefs.render(input.stage, variables);
4265
- const fields = {
4266
- stage: input.stage,
4267
- chapter,
4268
- digest,
4269
- min_words: snapshot.project.targets.words_per_chapter[0],
4270
- min_score: this.config.quality.minScore,
4271
- scene_types: [
4272
- "战斗/动作章",
4273
- "日常过渡章",
4274
- "情感爆发章",
4275
- "纯叙事章"
4276
- ],
4277
- platform_style: this.rulepack.platform[snapshot.project.platform],
4278
- genre_guide: this.rulepack.genres[snapshot.project.genre],
4279
- /** P4 must apply exclusions before its arithmetic closes. */
4280
- score_exclusions: this.rulepack.exclusions,
4281
- next_tool: NEXT_TOOL_BY_BRIEF[input.stage]
4282
- };
4283
- const existing = snapshot.pipeline.briefs[input.stage];
4284
- if (existing !== void 0 && existing.digest === digest) return {
4285
- data: {
4286
- stage: input.stage,
4287
- chapter,
4288
- digest,
4289
- title: rendered.title,
4290
- content: rendered.content,
4291
- content_hash: rendered.contentHash,
4292
- fields,
4293
- context
4294
- },
4295
- revision: snapshot.pipeline.revision,
4296
- warnings: []
4297
- };
4391
+ await this.load(input.root);
4298
4392
  return this.mutate({
4299
4393
  root: input.root,
4300
4394
  op: "getBrief",
4301
- run: async (fresh, ctx) => {
4302
- const nextPipeline = pipelineWith(fresh, ctx.revision, {
4395
+ run: async (snapshot, context) => {
4396
+ const chapter = input.chapter ?? snapshot.pipeline.chapter;
4397
+ if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `brief ${input.stage} belongs to active chapter ${snapshot.pipeline.chapter}, not ${chapter}`, `omit chapter or pass ${snapshot.pipeline.chapter}`);
4398
+ const contextPack = this.briefContextFor(input.stage, snapshot, chapter);
4399
+ const digest = this.briefDigest(input.stage, snapshot, contextPack);
4400
+ const variables = this.briefVariables(snapshot, chapter);
4401
+ const rendered = this.briefs.render(input.stage, variables);
4402
+ const fields = {
4403
+ stage: input.stage,
4404
+ chapter,
4405
+ digest,
4406
+ min_words: snapshot.project.targets.words_per_chapter[0],
4407
+ min_score: this.config.quality.minScore,
4408
+ scene_types: [
4409
+ "战斗/动作章",
4410
+ "日常过渡章",
4411
+ "情感爆发章",
4412
+ "纯叙事章"
4413
+ ],
4414
+ platform_style: this.rulepack.platform[snapshot.project.platform],
4415
+ genre_guide: this.rulepack.genres[snapshot.project.genre],
4416
+ /** P4 must apply exclusions before its arithmetic closes. */
4417
+ score_exclusions: this.rulepack.exclusions,
4418
+ next_tool: NEXT_TOOL_BY_BRIEF[input.stage],
4419
+ ...snapshot.planError === null ? {} : { plan_error: snapshot.planError }
4420
+ };
4421
+ const data = {
4422
+ stage: input.stage,
4423
+ chapter,
4424
+ digest,
4425
+ title: rendered.title,
4426
+ content: rendered.content,
4427
+ content_hash: rendered.contentHash,
4428
+ fields,
4429
+ context: contextPack
4430
+ };
4431
+ const existing = snapshot.pipeline.briefs[input.stage];
4432
+ if (existing !== void 0 && existing.digest === digest) return {
4433
+ files: [],
4434
+ data,
4435
+ replay: true
4436
+ };
4437
+ const nextPipeline = pipelineWith(snapshot, context.revision, {
4303
4438
  briefs: {
4304
- ...fresh.pipeline.briefs,
4439
+ ...snapshot.pipeline.briefs,
4305
4440
  [input.stage]: {
4306
4441
  issued_at: this.now().toISOString(),
4307
4442
  digest
@@ -4315,16 +4450,7 @@ var EngineService = class {
4315
4450
  text: yaml(nextPipeline)
4316
4451
  }],
4317
4452
  backup: false,
4318
- data: {
4319
- stage: input.stage,
4320
- chapter,
4321
- digest,
4322
- title: rendered.title,
4323
- content: rendered.content,
4324
- content_hash: rendered.contentHash,
4325
- fields,
4326
- context
4327
- }
4453
+ data
4328
4454
  };
4329
4455
  }
4330
4456
  });
@@ -4451,7 +4577,7 @@ var EngineService = class {
4451
4577
  const planPath = planChapterFile(chapter);
4452
4578
  const planText = await context.store.readText(planPath);
4453
4579
  const mergedPlan = {
4454
- ...(planText === null ? null : parseYamlLoose(planText)) ?? {
4580
+ ...(planText === null ? null : parsePlanYaml(planText, planPath)) ?? {
4455
4581
  schema_version: 1,
4456
4582
  chapter
4457
4583
  },
@@ -4559,6 +4685,7 @@ var EngineService = class {
4559
4685
  detail: `score ${input.total} is below the minimum ${this.config.quality.minScore}`,
4560
4686
  hint: "revise the chapter and submit a new P4 review"
4561
4687
  });
4688
+ const reviewDigest = sha256(stableJson(review));
4562
4689
  const nextPipeline = pipelineWith(snapshot, context.revision, {
4563
4690
  stage: next,
4564
4691
  rounds: {
@@ -4566,7 +4693,8 @@ var EngineService = class {
4566
4693
  review: snapshot.pipeline.rounds.review + 1
4567
4694
  },
4568
4695
  blocking,
4569
- last_action: action("novel_review", this.now(), sha256(stableJson(review)))
4696
+ review_digest: reviewDigest,
4697
+ last_action: action("novel_review", this.now(), reviewDigest)
4570
4698
  });
4571
4699
  return {
4572
4700
  files: [{
@@ -4669,11 +4797,12 @@ var EngineService = class {
4669
4797
  revise: snapshot.pipeline.rounds.revise + 1
4670
4798
  },
4671
4799
  blocking: [],
4800
+ ...patchedReview === null ? {} : { review_digest: sha256(stableJson(patchedReview)) },
4672
4801
  last_action: action("novel_revise", this.now(), sha256(input.content))
4673
4802
  });
4674
4803
  const planPath = planChapterFile(input.chapter);
4675
4804
  const planText = await context.store.readText(planPath);
4676
- const plan = planText === null ? null : parseYamlLoose(planText);
4805
+ const plan = planText === null ? null : parsePlanYaml(planText, planPath);
4677
4806
  const files = [{
4678
4807
  path: chapterFile(input.chapter),
4679
4808
  text: input.content
@@ -4745,7 +4874,7 @@ var EngineService = class {
4745
4874
  scope: "check",
4746
4875
  detail: violation.detail
4747
4876
  })), ...semanticResult.blocking] : [];
4748
- let nextPipeline = pipelineWith(snapshot, context.revision, {
4877
+ const nextPipeline = pipelineWith(snapshot, context.revision, {
4749
4878
  stage: nextStage,
4750
4879
  rounds: {
4751
4880
  ...snapshot.pipeline.rounds,
@@ -4754,24 +4883,6 @@ var EngineService = class {
4754
4883
  blocking: blockingItems,
4755
4884
  last_action: action("novel_check", this.now(), sha256(text))
4756
4885
  });
4757
- if (verdict === "fail" && nextPipeline.briefs.P6 !== void 0) {
4758
- const postCheck = {
4759
- ...snapshot,
4760
- check,
4761
- pipeline: nextPipeline
4762
- };
4763
- const digest = this.briefDigest("P6", postCheck, this.briefContextFor("P6", postCheck, input.chapter));
4764
- nextPipeline = {
4765
- ...nextPipeline,
4766
- briefs: {
4767
- ...nextPipeline.briefs,
4768
- P6: {
4769
- ...nextPipeline.briefs.P6,
4770
- digest
4771
- }
4772
- }
4773
- };
4774
- }
4775
4886
  return {
4776
4887
  files: [{
4777
4888
  path: checkFile(input.chapter),
@@ -4808,7 +4919,8 @@ var EngineService = class {
4808
4919
  run: async (snapshot, context) => {
4809
4920
  const already = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
4810
4921
  if (already !== void 0) {
4811
- if (already.archive_digest !== void 0 && already.archive_digest !== archiveDigest) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} is already archived with a different state_delta`, "archive is idempotent by chapter; start the next chapter instead of replaying old data");
4922
+ if (already.archive_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} has no archive_digest; the index entry is not trustworthy`, "restore the chapter index from backups/ or re-archive from the original manuscript");
4923
+ if (already.archive_digest !== archiveDigest) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${input.chapter} is already archived with a different state_delta`, "archive is idempotent by chapter; start the next chapter instead of replaying old data");
4812
4924
  const archivedText = await this.readChapter(snapshot, input.chapter);
4813
4925
  if (archivedText === null || sha256(archivedText) !== already.content_hash) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `archived chapter ${input.chapter} no longer matches its index`, "restore the chapter from backups/ before retrying");
4814
4926
  return {
@@ -4827,11 +4939,13 @@ var EngineService = class {
4827
4939
  const text = snapshot.text ?? "";
4828
4940
  const summary = snapshot.plan?.summary;
4829
4941
  if (summary === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "the draft has no summary; resubmit the draft with a summary", "call novel_write_draft with the full summary block");
4942
+ if (snapshot.review !== null && snapshot.pipeline.review_digest !== void 0 && snapshot.pipeline.review_digest !== sha256(stableJson(snapshot.review))) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review changed after submission; its digest no longer matches", "re-run novel_review instead of editing reviews/ directly");
4830
4943
  await this.assertGates(snapshot, "archiveChapter", {
4831
4944
  briefStage: "P8",
4832
4945
  text,
4833
4946
  summary,
4834
- review: snapshot.review
4947
+ review: snapshot.review,
4948
+ proof: snapshot.review?.proof ?? null
4835
4949
  });
4836
4950
  if (text === "") throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${input.chapter} has no draft`);
4837
4951
  const archiveBlockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
@@ -4992,7 +5106,7 @@ var EngineService = class {
4992
5106
  if (typeof input.content !== "object" || input.content === null || Array.isArray(input.content)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend content must be an object");
4993
5107
  const resolvedRoot = await this.resolveRoot(input.root);
4994
5108
  const absolute = this.storeFor(resolvedRoot).path(input.path);
4995
- const kind = MUTABLE_STATE[input.path];
5109
+ const kind = Object.hasOwn(MUTABLE_STATE, input.path) ? MUTABLE_STATE[input.path] : void 0;
4996
5110
  if (kind === void 0 || absolute.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `amend can only target state documents: ${Object.keys(MUTABLE_STATE).join(", ")}`, "edit novel content through the draft/revision tools, not amend");
4997
5111
  return this.mutate({
4998
5112
  root: resolvedRoot,
@@ -5259,6 +5373,7 @@ var EngineService = class {
5259
5373
  revision: snapshot.pipeline.revision,
5260
5374
  warnings: []
5261
5375
  };
5376
+ if (!Object.hasOwn(STATE_FILES, input.kind)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown state document "${String(input.kind)}"`, `allowed documents: ${Object.values(STATE_FILES).join(", ")}`);
5262
5377
  const path = joinRel(STATE_DIR, STATE_FILES[input.kind]);
5263
5378
  return await this.mutate({
5264
5379
  root: input.root,
@@ -5308,7 +5423,7 @@ var EngineService = class {
5308
5423
  stage: snapshot.pipeline.stage,
5309
5424
  revision: snapshot.pipeline.revision,
5310
5425
  rounds: snapshot.pipeline.rounds,
5311
- blocking: snapshot.pipeline.blocking,
5426
+ blocking: this.effectiveBlocking(snapshot),
5312
5427
  next: finished ? void 0 : this.nextStep(snapshot),
5313
5428
  finished,
5314
5429
  chapters: archived,
@@ -5318,31 +5433,93 @@ var EngineService = class {
5318
5433
  };
5319
5434
  }
5320
5435
  nextStep(snapshot) {
5321
- if (snapshot.pipeline.blocking.length > 0) {
5322
- const first = snapshot.pipeline.blocking[0];
5323
- return {
5324
- tool: "novel_status",
5325
- args: {},
5326
- reason: `${first.code}: ${first.detail}${first.hint === void 0 ? "" : ` (${first.hint})`}`
5327
- };
5328
- }
5436
+ const stage = snapshot.pipeline.stage;
5437
+ const chapter = snapshot.pipeline.chapter;
5438
+ const step = STAGE_NEXT[stage];
5329
5439
  const target = snapshot.project.targets.total_chapters;
5330
- if (snapshot.pipeline.stage === "done" && target !== null && snapshot.chapters.chapters.length >= target) return {
5440
+ if (stage === "done" && target !== null && snapshot.chapters.chapters.length >= target) return {
5331
5441
  tool: "novel_export",
5332
5442
  args: { kind: "epub" },
5333
5443
  reason: "全书已完成;可导出成书,或结束本书"
5334
5444
  };
5335
- const step = STAGE_NEXT[snapshot.pipeline.stage];
5336
- const args = snapshot.pipeline.stage === "ready_to_archive" ? {
5337
- stage: "P8",
5338
- chapter: snapshot.pipeline.chapter
5339
- } : { chapter: snapshot.pipeline.chapter };
5445
+ const roundsBlock = this.roundsLimitBlock(snapshot);
5446
+ if (roundsBlock !== null) return {
5447
+ tool: "novel_rollback",
5448
+ args: {
5449
+ chapter,
5450
+ to_stage: "P4"
5451
+ },
5452
+ reason: `${roundsBlock.code}: ${roundsBlock.detail}; ${roundsBlock.hint ?? ""}`.trim()
5453
+ };
5454
+ const first = this.effectiveBlocking(snapshot)[0];
5455
+ const detail = first === void 0 ? "" : `${first.code}: ${first.detail}${first.hint === void 0 ? "" : ` (${first.hint})`}`;
5456
+ const briefStage = STATUS_BRIEF[stage];
5457
+ const followUp = (stage === "P5" || stage === "P6") && snapshot.pipeline.blocking.length > 0 ? "novel_revise" : step.tool;
5458
+ if (briefStage !== null && !this.briefIsCurrent(snapshot, briefStage)) return {
5459
+ tool: "novel_brief",
5460
+ args: {
5461
+ stage: briefStage,
5462
+ chapter
5463
+ },
5464
+ reason: detail === "" ? `fetch the ${briefStage} brief before ${followUp}` : `${detail}; fetch the ${briefStage} brief, then ${followUp}`
5465
+ };
5466
+ if (snapshot.pipeline.blocking.length > 0) {
5467
+ if (stage === "P5" || stage === "P6") return {
5468
+ tool: "novel_revise",
5469
+ args: { chapter },
5470
+ reason: `${detail}; fix it with novel_revise`
5471
+ };
5472
+ return {
5473
+ tool: step.tool,
5474
+ args: this.nextArgs(stage, chapter),
5475
+ reason: `${detail}; ${step.reason}`
5476
+ };
5477
+ }
5340
5478
  return {
5341
5479
  tool: step.tool,
5342
- args,
5480
+ args: this.nextArgs(stage, chapter),
5343
5481
  reason: step.reason
5344
5482
  };
5345
5483
  }
5484
+ /** A computed blocker for the retry budget the next operation would consume. */
5485
+ roundsLimitBlock(snapshot) {
5486
+ const stage = snapshot.pipeline.stage;
5487
+ if (![
5488
+ "P4",
5489
+ "P5",
5490
+ "P6"
5491
+ ].includes(stage)) return null;
5492
+ const counter = stage === "P4" ? "review" : stage === "P5" ? "revise" : "check";
5493
+ if (!roundsExhausted(snapshot.pipeline.rounds, counter)) return null;
5494
+ return {
5495
+ code: "PG-11",
5496
+ scope: "rounds",
5497
+ detail: `round limit reached for ${counter} (review ${snapshot.pipeline.rounds.review}, revise ${snapshot.pipeline.rounds.revise}, check ${snapshot.pipeline.rounds.check})`,
5498
+ hint: "call novel_rollback with to_stage P4; the manuscript is kept for reference"
5499
+ };
5500
+ }
5501
+ /** Pipeline blockers plus budget exhaustion, so status/panel cannot say "all clear". */
5502
+ effectiveBlocking(snapshot) {
5503
+ const extra = this.roundsLimitBlock(snapshot);
5504
+ if (extra === null) return snapshot.pipeline.blocking;
5505
+ return snapshot.pipeline.blocking.some((item) => item.code === extra.code) ? snapshot.pipeline.blocking : [...snapshot.pipeline.blocking, extra];
5506
+ }
5507
+ /** Whether the stored brief still signs the current snapshot. */
5508
+ briefIsCurrent(snapshot, stage) {
5509
+ const record = snapshot.pipeline.briefs[stage];
5510
+ if (record === void 0) return false;
5511
+ const context = this.briefContextFor(stage, snapshot, snapshot.pipeline.chapter);
5512
+ return record.digest === this.briefDigest(stage, snapshot, context);
5513
+ }
5514
+ /** Arguments valid for the tool `STAGE_NEXT` recommends at this stage. */
5515
+ nextArgs(stage, chapter) {
5516
+ switch (stage) {
5517
+ case "idle":
5518
+ case "P2":
5519
+ case "done": return {};
5520
+ default: return { chapter };
5521
+ }
5522
+ }
5346
5523
  async metricsFor(snapshot, chapter) {
5347
5524
  if (chapter === snapshot.pipeline.chapter && snapshot.text !== null) return countWordMetrics(snapshot.text);
5348
5525
  const text = await this.readChapter(snapshot, chapter);
@@ -5360,7 +5537,8 @@ var EngineService = class {
5360
5537
  }
5361
5538
  async chaptersForExport(snapshot, chapter) {
5362
5539
  const records = snapshot.chapters.chapters;
5363
- if (chapter !== void 0 && !records.some((record) => record.num === chapter)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not in the archive index`, "archive the chapter first, or export all archived chapters");
5540
+ const activeDraft = chapter !== void 0 && chapter === snapshot.pipeline.chapter && snapshot.text !== null;
5541
+ if (chapter !== void 0 && !records.some((record) => record.num === chapter) && !activeDraft) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not archived and is not the active draft`, "archive the chapter first, or pass the active chapter to export its draft");
5364
5542
  const selected = chapter === void 0 ? records : records.filter((record) => record.num === chapter);
5365
5543
  const chapters = [];
5366
5544
  for (const record of selected) {
@@ -5372,7 +5550,7 @@ var EngineService = class {
5372
5550
  text
5373
5551
  });
5374
5552
  }
5375
- if (chapters.length === 0 && snapshot.text !== null && chapter === void 0) chapters.push({
5553
+ if (chapters.length === 0 && snapshot.text !== null && (chapter === void 0 || chapter === snapshot.pipeline.chapter)) chapters.push({
5376
5554
  num: snapshot.pipeline.chapter,
5377
5555
  title: snapshot.plan?.title ?? "未命名",
5378
5556
  text: snapshot.text
@@ -5419,6 +5597,7 @@ var EngineService = class {
5419
5597
  timeline: snapshot.timeline,
5420
5598
  relationships: snapshot.relationships,
5421
5599
  plan: snapshot.plan,
5600
+ plan_error: snapshot.planError,
5422
5601
  review: snapshot.review,
5423
5602
  check: snapshot.check,
5424
5603
  text: snapshot.text,
@@ -5435,6 +5614,7 @@ var EngineService = class {
5435
5614
  chapter: snapshot.pipeline.chapter,
5436
5615
  pipelineStage: snapshot.pipeline.stage,
5437
5616
  rounds: snapshot.pipeline.rounds,
5617
+ min_score: this.config.quality.minScore,
5438
5618
  rulepack: `${this.rulepack.name}@${this.rulepack.version}`,
5439
5619
  context
5440
5620
  }));
@@ -5509,8 +5689,11 @@ var EngineService = class {
5509
5689
  throw new EngineBlockedError(result.blocking.every((item) => item.code === "PG-09") ? ERROR_CODES.READING_PROOF_INVALID : ERROR_CODES.GATE_BLOCKED, `${operation} blocked by ${result.blocking.length} gate(s), first: ${first.code} ${first.detail}`, result.blocking, result.blocking.map((item) => `${item.code}: ${item.hint ?? item.detail}`).join(" | "));
5510
5690
  }
5511
5691
  }
5512
- assertChapter(snapshot, chapter) {
5692
+ assertChapterParameter(chapter) {
5513
5693
  if (!Number.isInteger(chapter) || chapter < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter must be a positive integer (got ${String(chapter)})`);
5694
+ }
5695
+ assertChapter(snapshot, chapter) {
5696
+ this.assertChapterParameter(chapter);
5514
5697
  if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not the active chapter (${snapshot.pipeline.chapter})`, "archive the active chapter before starting the next one");
5515
5698
  }
5516
5699
  async load(root, retries = 1) {
@@ -5554,7 +5737,13 @@ var EngineService = class {
5554
5737
  if (previous !== null) previousTexts.push(previous);
5555
5738
  }
5556
5739
  const planRaw = await store.readText(planChapterFile(chapter));
5557
- const plan = planRaw === null ? null : parseYamlLoose(planRaw);
5740
+ let plan = null;
5741
+ let planError = null;
5742
+ if (planRaw !== null) try {
5743
+ plan = parsePlanYaml(planRaw, planChapterFile(chapter));
5744
+ } catch (error) {
5745
+ planError = error instanceof Error ? error.message : String(error);
5746
+ }
5558
5747
  const review = await store.readDoc(reviewFile(chapter), "review");
5559
5748
  const check = await store.readDoc(checkFile(chapter), "check");
5560
5749
  const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
@@ -5579,6 +5768,7 @@ var EngineService = class {
5579
5768
  previousText,
5580
5769
  previousTexts,
5581
5770
  plan,
5771
+ planError,
5582
5772
  review,
5583
5773
  check
5584
5774
  };
@@ -5739,6 +5929,7 @@ var EngineService = class {
5739
5929
  previousText: null,
5740
5930
  previousTexts: [],
5741
5931
  plan: null,
5932
+ planError: null,
5742
5933
  review: null,
5743
5934
  check: null
5744
5935
  };
@@ -5753,6 +5944,17 @@ const NEXT_TOOL_BY_BRIEF = {
5753
5944
  P6: "novel_check",
5754
5945
  P8: "novel_archive"
5755
5946
  };
5947
+ /** The task book each stage must see before its next write operation. */
5948
+ const STATUS_BRIEF = {
5949
+ idle: "P1",
5950
+ P2: "P2",
5951
+ P3: "P3",
5952
+ P4: "P4",
5953
+ P5: "P5",
5954
+ P6: "P6",
5955
+ ready_to_archive: "P8",
5956
+ done: "P1"
5957
+ };
5756
5958
  const yaml = (value) => stringify(value, { lineWidth: 0 });
5757
5959
  const emptyChapters = () => ({
5758
5960
  schema_version: 1,
@@ -5778,6 +5980,7 @@ const pipelineWith = (snapshot, revision, patch) => ({
5778
5980
  rounds: snapshot.pipeline.rounds,
5779
5981
  briefs: snapshot.pipeline.briefs,
5780
5982
  blocking: snapshot.pipeline.blocking,
5983
+ review_digest: snapshot.pipeline.review_digest,
5781
5984
  last_action: snapshot.pipeline.last_action,
5782
5985
  ...patch
5783
5986
  });
@@ -5817,7 +6020,17 @@ const stableJson = (value) => {
5817
6020
  };
5818
6021
  return JSON.stringify(normalize(value));
5819
6022
  };
5820
- const parseYamlLoose = (text) => parse(text);
6023
+ /** Parse a plan file with a machine code instead of leaking a YAML parser error. */
6024
+ const parsePlanYaml = (text, path) => {
6025
+ let parsed;
6026
+ try {
6027
+ parsed = parse(text);
6028
+ } catch (error) {
6029
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix the plan YAML; the engine will not silently discard a corrupt plan", { cause: error });
6030
+ }
6031
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} must be a YAML mapping`, "rewrite the plan as a mapping (title, scene_type, narrator, ...)");
6032
+ return parsed;
6033
+ };
5821
6034
  /** Line diff with a shared prefix/suffix, enough for revision review. */
5822
6035
  function diffLines(before, after) {
5823
6036
  const left = before === "" ? [] : before.split("\n");
@@ -5867,7 +6080,10 @@ const asJson = (value, field) => {
5867
6080
  };
5868
6081
  /** Turn an engine failure into the message shape tools surface. */
5869
6082
  const fail = (error) => {
5870
- if (error instanceof EngineError) throw new Error(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}`);
6083
+ if (error instanceof EngineError) {
6084
+ const blocking = error instanceof EngineBlockedError && error.blocking.length > 0 ? `\nblocking: ${JSON.stringify(error.blocking)}` : "";
6085
+ throw new Error(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}${blocking}`);
6086
+ }
5871
6087
  throw error;
5872
6088
  };
5873
6089
  /** Wrap an execute body with cancellation and a machine-code error. */
@@ -5921,7 +6137,7 @@ const stringArray = {
5921
6137
  };
5922
6138
  const root = {
5923
6139
  type: "string",
5924
- description: "Project root; defaults to the configured root."
6140
+ description: "Project root. When omitted, resolves to the last active project, then the registry active root, then the configured projectRoot; pass it explicitly when several projects exist."
5925
6141
  };
5926
6142
  /**
5927
6143
  * The eighteen tools are thin adapters: narrow parameters, call one service
@@ -5980,8 +6196,11 @@ function createNovelTools(engine) {
5980
6196
  schema: { type: "json" },
5981
6197
  render: (_args, raw) => {
5982
6198
  const value = payload(raw);
6199
+ const active = value.active_chapter ?? value.chapter;
6200
+ const where = active === value.chapter ? `第 ${value.chapter} 章` : `当前第 ${active} 章 · 查询第 ${value.chapter} 章字数`;
5983
6201
  return text([
5984
- `《${value.project.title}》 第 ${value.chapter} 章 · ${value.stage}`,
6202
+ `《${value.project.title}》 ${where} · ${value.stage}`,
6203
+ ...value.rounds === void 0 ? [] : [`轮次:评审 ${value.rounds.review} / 修订 ${value.rounds.revise} / 校验 ${value.rounds.check}`],
5985
6204
  value.blocking.length === 0 ? "门控:全部通过" : `门控:${value.blocking.length} 条阻塞`,
5986
6205
  ...value.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
5987
6206
  `下一步:${value.next.tool} — ${value.next.reason}`
@@ -6017,7 +6236,7 @@ function createNovelTools(engine) {
6017
6236
  schema: { type: "json" },
6018
6237
  render: (_args, raw) => {
6019
6238
  const value = payload(raw);
6020
- return text(`${value.content}\n\n---\nbrief digest: ${value.digest}${value.context === void 0 ? "" : `\ncontext:\n${JSON.stringify(value.context, null, 2)}`}`);
6239
+ return text(`${value.content}\n\n---\nbrief digest: ${value.digest}${value.fields === void 0 ? "" : `\nfields:\n${JSON.stringify(value.fields)}`}${value.context === void 0 ? "" : `\ncontext:\n${JSON.stringify(value.context)}`}`);
6021
6240
  }
6022
6241
  },
6023
6242
  execute: guarded(async (args) => engine.getBrief({
@@ -6440,6 +6659,11 @@ function createNovelTools(engine) {
6440
6659
  ];
6441
6660
  }
6442
6661
  // src/commands/admin.ts
6662
+ /** Registry commands need an explicit root; do not pass undefined into path.resolve. */
6663
+ const rootArg = (args) => {
6664
+ if (typeof args.root !== "string" || args.root.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "registry operation requires a non-empty root", "pass {\"root\":\"/absolute/project/path\"}");
6665
+ return args.root;
6666
+ };
6443
6667
  /**
6444
6668
  * The ten tools hidden when `tools.mode=core`, plus the explicit
6445
6669
  * registry/settings-pack operations that are command-only by design (§6.8).
@@ -6471,9 +6695,9 @@ const OPERATIONS$1 = {
6471
6695
  novel_pipeline: (engine, args) => engine.advise(args),
6472
6696
  novel_state: (engine, args) => engine.queryState(args),
6473
6697
  novel_registry_list: (engine) => engine.listProjects(),
6474
- novel_registry_register: (engine, args) => engine.registerProject({ root: args.root }),
6475
- novel_registry_activate: (engine, args) => engine.setActiveProject({ root: args.root }),
6476
- novel_registry_forget: (engine, args) => engine.forgetProject({ root: args.root }),
6698
+ novel_registry_register: (engine, args) => engine.registerProject({ root: rootArg(args) }),
6699
+ novel_registry_activate: (engine, args) => engine.setActiveProject({ root: rootArg(args) }),
6700
+ novel_registry_forget: (engine, args) => engine.forgetProject({ root: rootArg(args) }),
6477
6701
  novel_import: (engine, args) => engine.importPack(args)
6478
6702
  };
6479
6703
  const ADMIN_OPERATION_NAMES = Object.keys(OPERATIONS$1);
@@ -6500,7 +6724,7 @@ function parseAdminArguments(raw, operation) {
6500
6724
  }
6501
6725
  /** Run one hidden operation and render its payload for the command result. */
6502
6726
  async function runAdminOperation(engine, operation, rawArguments) {
6503
- const handler = OPERATIONS$1[operation];
6727
+ const handler = Object.hasOwn(OPERATIONS$1, operation) ? OPERATIONS$1[operation] : void 0;
6504
6728
  if (handler === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, `未知子命令 "${operation}"`, `available: ${ADMIN_OPERATION_NAMES.join(", ")}`);
6505
6729
  const parsed = parseAdminArguments(rawArguments, operation);
6506
6730
  const result = await handler(engine, parsed.root === void 0 && typeof parsed.project_root === "string" ? {
@@ -7200,6 +7424,7 @@ function apply(ctx, config) {
7200
7424
  ctx.emit?.(event.name, event.payload);
7201
7425
  };
7202
7426
  const engine = new EngineService(engineConfig, () => loadRulepack(), { onEvent: emit });
7427
+ ctx.effect(() => () => engine.dispose(), `${name}: engine dispose`);
7203
7428
  ctx.provide?.("novelEngine", engine);
7204
7429
  for (const tool of createNovelTools(engine)) {
7205
7430
  if (toolsMode === "core" && !CORE_TOOLS.includes(tool.name)) continue;
@@ -7241,4 +7466,4 @@ function connectionFence(scope) {
7241
7466
  }
7242
7467
  if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
7243
7468
  }
7244
- export { BriefLibrary, Config, ERROR_CODES, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, OPERATIONS, PLATFORMS, SCENE_TYPES, SCHEMA_VERSION, applicableGates, apply, blockedError, briefStageFor, chapterHeading, evaluateGates, faultError, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, projectChapter, projectChapters, projectGates, projectStatus, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, verifyReference };
7469
+ export { BriefLibrary, Config, ERROR_CODES, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, OPERATIONS, PLATFORMS, SCENE_TYPES, SCHEMA_VERSION, applicableGates, apply, blockedError, briefStageFor, chapterHeading, evaluateGates, faultError, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, verifyReference };