dsh-novel-write-engine 0.2.0 → 0.2.2

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
@@ -9,9 +9,14 @@ import { fileURLToPath } from "node:url";
9
9
  import { deflateRawSync } from "node:zlib";
10
10
  import { defineTool } from "@deepseek-ai/dsh-tools";
11
11
  // src/constants.ts
12
+ /** Panel and session identifiers. */
13
+ const PLUGIN_NAME = "dsh-novel-write-engine";
12
14
  const PANEL_ID = "novel-workshop";
13
15
  const PANEL_ROUTE = "/api/novel-write-engine/panel";
14
16
  const SKILL_NAME = "novel-write-engine";
17
+ const REMOTE_VERSION = 4;
18
+ /** The panel is a recent-history view; the wire payload and UI use one cap. */
19
+ const PANEL_CHAPTER_LIMIT = 30;
15
20
  /** Project-relative locations. Every write goes through these constants. */
16
21
  const PROJECT_FILE = "novel.yaml";
17
22
  const OUTLINE_FILE = "outline/outline.yaml";
@@ -23,9 +28,16 @@ const REVIEW_DIR = "reviews";
23
28
  const CHECK_DIR = "checks";
24
29
  const EXPORT_DIR = "exports";
25
30
  const BACKUP_DIR = "backups";
31
+ const HISTORY_DIR = "history";
32
+ /** Brief receipts are append-only evidence, separate from content transactions. */
33
+ const BRIEF_RECEIPTS_FILE = "state/briefs.yaml";
34
+ const OUTLINE_DIR = "outline";
26
35
  const LOCK_FILE = ".engine.lock";
27
36
  const STAGING_DIR = ".staging";
28
37
  const REGISTRY_FILE = "projects.yaml";
38
+ const SESSION_BINDINGS_FILE = "session-bindings.yaml";
39
+ const METADATA_LOCK_FILE = ".metadata.lock";
40
+ const ARCHIVE_DIGESTS_FILE = "state/archive-digests.json";
29
41
  /** Files owned by the engine under `state/`. */
30
42
  const STATE_FILES = {
31
43
  pipeline: "pipeline.yaml",
@@ -47,6 +59,21 @@ const planWorldFile = (chapter) => `${PLAN_DIR}/ch${String(chapter).padStart(3,
47
59
  const planChapterFile = (chapter) => `${PLAN_DIR}/ch${String(chapter).padStart(3, "0")}.chapter.yaml`;
48
60
  const reviewFile = (chapter) => `${REVIEW_DIR}/ch${String(chapter).padStart(3, "0")}.review.yaml`;
49
61
  const checkFile = (chapter) => `${CHECK_DIR}/ch${String(chapter).padStart(3, "0")}.check.yaml`;
62
+ /** Immutable-ish prior versions; the primary paths always hold the current one. */
63
+ const historyChapter = (chapter) => `ch${String(chapter).padStart(3, "0")}`;
64
+ const historyRevision = (revision) => `r${String(Math.max(0, revision)).padStart(4, "0")}`;
65
+ const reviewHistoryFile = (chapter, revision) => `${HISTORY_DIR}/reviews/${historyChapter(chapter)}.${historyRevision(revision)}.review.yaml`;
66
+ const checkHistoryFile = (chapter, revision) => `${HISTORY_DIR}/checks/${historyChapter(chapter)}.${historyRevision(revision)}.check.yaml`;
67
+ const chapterHistoryFile = (chapter, revision) => `${HISTORY_DIR}/chapters/${historyChapter(chapter)}.${historyRevision(revision)}.md`;
68
+ const deprecatedHistoryFile = (chapter, revision) => `${HISTORY_DIR}/deprecated/${historyChapter(chapter)}.${historyRevision(revision)}.md`;
69
+ const planHistoryFile = (chapter, revision) => `${HISTORY_DIR}/plans/${historyChapter(chapter)}.${historyRevision(revision)}.chapter.yaml`;
70
+ const HISTORY_KINDS = [
71
+ "reviews",
72
+ "checks",
73
+ "chapters",
74
+ "deprecated",
75
+ "plans"
76
+ ];
50
77
  /** Limits that are structural, not taste. */
51
78
  const LIMITS = {
52
79
  /** P4 <-> P5/P6 retries before a forced rollback. */
@@ -72,6 +99,11 @@ const LIMITS = {
72
99
  maxWorldbookTextChars: 2e3,
73
100
  maxSearchQueryChars: 200
74
101
  };
102
+ /** Re-exported enums make adapter code readable without importing types twice. */
103
+ const SCENE_TYPE_DEFAULT = "日常过渡章";
104
+ const DEFAULT_GENRE = "都市现代";
105
+ const DEFAULT_PLATFORM = "fanqie";
106
+ const INITIAL_STAGE = "idle";
75
107
  /** The order used to render the nine-phase progress strip. */
76
108
  const PHASE_STRIP = [
77
109
  "P1",
@@ -84,6 +116,37 @@ const PHASE_STRIP = [
84
116
  "P8",
85
117
  "P9"
86
118
  ];
119
+ /** Tool names, kept here so the arch test can assert one namespace. */
120
+ const TOOL_NAMES = [
121
+ "novel_init",
122
+ "novel_status",
123
+ "novel_brief",
124
+ "novel_plan_world",
125
+ "novel_plan_chapter",
126
+ "novel_write_draft",
127
+ "novel_review",
128
+ "novel_revise",
129
+ "novel_check",
130
+ "novel_archive",
131
+ "novel_word_count",
132
+ "novel_state",
133
+ "novel_amend",
134
+ "novel_rollback",
135
+ "novel_diff",
136
+ "novel_export",
137
+ "novel_audit",
138
+ "novel_pipeline",
139
+ "novel_blueprint",
140
+ "novel_outline",
141
+ "novel_outline_import",
142
+ "novel_worldbook",
143
+ "novel_search",
144
+ "novel_doctor",
145
+ "novel_report",
146
+ "novel_guide",
147
+ "novel_reopen",
148
+ "novel_restore"
149
+ ];
87
150
  /** The ten high-frequency tools kept when `tools.mode = core`. */
88
151
  const CORE_TOOLS = [
89
152
  "novel_init",
@@ -134,7 +197,7 @@ const SCENE_TYPES = [
134
197
  "情感爆发章",
135
198
  "纯叙事章"
136
199
  ];
137
- /** All thirty-one service operations (22 inherited from 0.1.x, 9 book-level). */
200
+ /** All thirty-two service operations (22 inherited + 9 book-level + restoreBackup). */
138
201
  const OPERATIONS = [
139
202
  "initProject",
140
203
  "archiveChapter",
@@ -166,11 +229,15 @@ const OPERATIONS = [
166
229
  "panelSummary",
167
230
  "panelChapters",
168
231
  "panelGates",
169
- "stats"
232
+ "stats",
233
+ "restoreBackup"
170
234
  ];
171
235
  /** Error families map one-to-one to the three repair actions a caller can take. */
172
236
  const ERROR_CODES = {
173
237
  PROJECT_NOT_FOUND: "ENGINE_PROJECT_NOT_FOUND",
238
+ PROJECT_AMBIGUOUS: "ENGINE_PROJECT_AMBIGUOUS",
239
+ PROJECT_MISSING: "ENGINE_PROJECT_MISSING",
240
+ PROJECT_IDENTITY_MISMATCH: "ENGINE_PROJECT_IDENTITY_MISMATCH",
174
241
  PROJECT_EXISTS: "ENGINE_PROJECT_EXISTS",
175
242
  STAGE_ORDER: "ENGINE_STAGE_ORDER",
176
243
  GATE_BLOCKED: "ENGINE_GATE_BLOCKED",
@@ -676,8 +743,10 @@ const STAGE_NEXT = {
676
743
  const CJK = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g;
677
744
  const ENGLISH_WORD = /[A-Za-z]+/g;
678
745
  const NUMBER = /(?<![A-Za-z0-9])[0-9]+(?![A-Za-z0-9])/g;
679
- const CHINESE_PUNCT = /[\u3000-\u303f\uff00-\uffef]/g;
680
- const ENGLISH_PUNCT = /[^\p{L}\p{N}_\s\u3000-\u303f\uff00-\uffef]/gu;
746
+ const CHINESE_PUNCT = /[\u3000-\u303f\uff00-\uffef\u2018\u2019\u201c\u201d\u2014\u2026]/g;
747
+ const ENGLISH_PUNCT = /[^\p{L}\p{N}_\s\u3000-\u303f\uff00-\uffef\u2018\u2019\u201c\u201d\u2014\u2026]/gu;
748
+ /** A line participates in dialogue if it contains any of the four common quote pairs. */
749
+ const DIALOGUE_MARKS = /["“”「」『』]/;
681
750
  const matchCount = (text, pattern) => text.match(pattern)?.length ?? 0;
682
751
  /**
683
752
  * The one word-count rule of the engine.
@@ -694,15 +763,17 @@ function countWordMetrics(text) {
694
763
  const numbers = matchCount(text, NUMBER);
695
764
  const chinesePunct = matchCount(text, CHINESE_PUNCT);
696
765
  const englishPunct = matchCount(text, ENGLISH_PUNCT);
697
- const dialogueLines = nonEmptyLines.filter((line) => line.includes("\"")).length;
766
+ const dialogueLines = nonEmptyLines.filter((line) => DIALOGUE_MARKS.test(line)).length;
698
767
  const paragraphs = splitParagraphs(text);
768
+ const punctuation = chinesePunct + englishPunct;
699
769
  return {
700
- total_word_count: cjkChars + englishWords + numbers + chinesePunct + englishPunct,
770
+ total_word_count: cjkChars + englishWords + numbers + punctuation,
701
771
  cjk_chars: cjkChars,
702
772
  english_words: englishWords,
703
773
  numbers,
704
774
  chinese_punct: chinesePunct,
705
775
  english_punct: englishPunct,
776
+ punctuation_chars: punctuation,
706
777
  non_ws_chars: matchCount(text, /\S/g),
707
778
  total_lines: lines.length,
708
779
  non_empty_lines: nonEmptyLines.length,
@@ -768,7 +839,10 @@ const SEMANTIC_RULES = {
768
839
  ADJECTIVE_DENSITY: "ADJECTIVE_DENSITY",
769
840
  PLEASURE_DENSITY: "PLEASURE_DENSITY",
770
841
  SENSORY_DENSITY: "SENSORY_DENSITY",
771
- WATER_WORDS: "WATER_WORDS"
842
+ WATER_WORDS: "WATER_WORDS",
843
+ DIALOGUE_RATIO: "DIALOGUE_RATIO",
844
+ NUMERIC_CONTRADICTION: "NUMERIC_CONTRADICTION",
845
+ NUMERIC_FACT_MISMATCH: "NUMERIC_FACT_MISMATCH"
772
846
  };
773
847
  /** Cross-chapter families. */
774
848
  const CROSS_RULES = {
@@ -1040,6 +1114,142 @@ const slidingWindows = (text, size) => {
1040
1114
  if (windows[windows.length - 1] !== tail) windows.push(tail);
1041
1115
  return windows;
1042
1116
  };
1117
+ const dialogueRatio = (ctx) => {
1118
+ const target = ctx.targets?.dialogue_ratio;
1119
+ if (target === void 0) return [];
1120
+ const ratio = countWordMetrics(ctx.text).dialogue_ratio;
1121
+ if (ratio >= target[0] && ratio <= target[1]) return [];
1122
+ return [v(SEMANTIC_RULES.DIALOGUE_RATIO, "advisory", 1, `dialogue ratio ${Math.round(ratio * 1e3) / 10}% is outside target ${Math.round(target[0] * 1e3) / 10}%..${Math.round(target[1] * 1e3) / 10}% (quoted lines / non-empty lines)`)];
1123
+ };
1124
+ const CN_DIGITS = {
1125
+ 零: 0,
1126
+ 一: 1,
1127
+ 二: 2,
1128
+ 两: 2,
1129
+ 三: 3,
1130
+ 四: 4,
1131
+ 五: 5,
1132
+ 六: 6,
1133
+ 七: 7,
1134
+ 八: 8,
1135
+ 九: 9
1136
+ };
1137
+ const CN_UNITS = {
1138
+ 十: 10,
1139
+ 百: 100,
1140
+ 千: 1e3,
1141
+ 万: 1e4
1142
+ };
1143
+ /** Parse a small Chinese/Arabic number; null means the token was not a number. */
1144
+ function parseSmallNumber(raw) {
1145
+ if (/^\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
1146
+ let total = 0;
1147
+ let section = 0;
1148
+ let digit = null;
1149
+ for (const char of raw) {
1150
+ if (char in CN_DIGITS) {
1151
+ digit = CN_DIGITS[char];
1152
+ continue;
1153
+ }
1154
+ const unit = CN_UNITS[char];
1155
+ if (unit === void 0) return null;
1156
+ if (unit === 1e4) {
1157
+ section = (section + (digit ?? 0)) * unit;
1158
+ total += section;
1159
+ section = 0;
1160
+ digit = null;
1161
+ } else {
1162
+ section += (digit ?? 1) * unit;
1163
+ digit = null;
1164
+ }
1165
+ }
1166
+ return total + section + (digit ?? 0);
1167
+ }
1168
+ const NUMBER_TOKEN = "[零一二两三四五六七八九十百千万]+|\\d+(?:\\.\\d+)?";
1169
+ const DISTANCE_RE = new RegExp(`(${NUMBER_TOKEN})\\s*(海里|公里|千米|米|里)`, "g");
1170
+ const DURATION_RE = new RegExp(`(${NUMBER_TOKEN})\\s*个?\\s*(小时|钟头|分钟)`, "g");
1171
+ const SPEED_RE = new RegExp(`(?:时速|每小时|速度(?:是|为)?)\\s*(${NUMBER_TOKEN})\\s*(海里|公里|千米|米)`, "g");
1172
+ const METRES = {
1173
+ 米: 1,
1174
+ 里: 500,
1175
+ 公里: 1e3,
1176
+ 千米: 1e3,
1177
+ 海里: 1852
1178
+ };
1179
+ const HOURS = {
1180
+ 分钟: 1 / 60,
1181
+ 小时: 1,
1182
+ 钟头: 1
1183
+ };
1184
+ const collect = (text, pattern, units) => {
1185
+ const result = [];
1186
+ for (const match of text.matchAll(pattern)) {
1187
+ const value = parseSmallNumber(match[1] ?? "");
1188
+ if (value === null || units[match[2] ?? ""] === void 0) continue;
1189
+ result.push({
1190
+ value,
1191
+ unit: match[2],
1192
+ index: match.index ?? 0,
1193
+ raw: match[0]
1194
+ });
1195
+ }
1196
+ return result;
1197
+ };
1198
+ const numericContradiction = (ctx) => {
1199
+ const explicit = collect(ctx.text, SPEED_RE, METRES).map((quantity) => ({
1200
+ at: quantity.index,
1201
+ kmh: quantity.value * METRES[quantity.unit] / 1e3,
1202
+ raw: quantity.raw
1203
+ }));
1204
+ const distances = collect(ctx.text, DISTANCE_RE, METRES);
1205
+ const durations = collect(ctx.text, DURATION_RE, HOURS);
1206
+ const speeds = [];
1207
+ if (distances.length > 0 && durations.length > 0) for (const distance of distances) {
1208
+ const nearest = durations.map((duration) => ({
1209
+ duration,
1210
+ distance: Math.abs(duration.index - distance.index)
1211
+ })).sort((left, right) => left.distance - right.distance)[0];
1212
+ if (nearest === void 0 || nearest.distance > 60) continue;
1213
+ const metres = distance.value * METRES[distance.unit];
1214
+ const hours = nearest.duration.value * HOURS[nearest.duration.unit];
1215
+ if (hours <= 0) continue;
1216
+ speeds.push({
1217
+ at: Math.min(distance.index, nearest.duration.index),
1218
+ kmh: metres / 1e3 / hours,
1219
+ raw: `${distance.raw} / ${nearest.duration.raw}`
1220
+ });
1221
+ }
1222
+ const anchors = explicit.length > 0 ? explicit : speeds;
1223
+ if (anchors.length < 2) return [];
1224
+ const positive = anchors.filter((anchor) => anchor.kmh > 0);
1225
+ if (positive.length < 2) return [];
1226
+ const min = Math.min(...positive.map((anchor) => anchor.kmh));
1227
+ const max = Math.max(...positive.map((anchor) => anchor.kmh));
1228
+ if (max / Math.max(1e-4, min) < 1.5) return [];
1229
+ const firstNeedle = positive[0].raw.split(" / ")[0];
1230
+ const paragraphIndex = splitParagraphs(ctx.text).findIndex((paragraph) => paragraph.includes(firstNeedle));
1231
+ return [v(SEMANTIC_RULES.NUMERIC_CONTRADICTION, "advisory", Math.max(1, paragraphIndex + 1), `numeric statements imply different speeds (${min.toFixed(1)}–${max.toFixed(1)} km/h): ${positive.slice(0, 3).map((anchor) => anchor.raw).join("; ")}`)];
1232
+ };
1233
+ const escapePattern = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1234
+ const numericFactMismatch = (ctx) => {
1235
+ if (ctx.facts === void 0 || ctx.facts.length === 0) return [];
1236
+ const results = [];
1237
+ const paragraphs = splitParagraphs(ctx.text);
1238
+ for (const fact of ctx.facts) {
1239
+ if (fact.alias.trim() === "" || !Number.isFinite(fact.value)) continue;
1240
+ const tolerance = fact.tolerance ?? 0;
1241
+ const pattern = new RegExp(`${escapePattern(fact.alias)}\\s*(?:是|为|约|有|:|:)?\\s*(${NUMBER_TOKEN})\\s*${escapePattern(fact.unit)}`, "g");
1242
+ for (const match of ctx.text.matchAll(pattern)) {
1243
+ const value = parseSmallNumber(match[1] ?? "");
1244
+ if (value === null) continue;
1245
+ if (Math.abs(value - fact.value) <= tolerance) continue;
1246
+ const paragraph = paragraphs.findIndex((candidate) => candidate.includes(match[0]));
1247
+ results.push(v(SEMANTIC_RULES.NUMERIC_FACT_MISMATCH, "advisory", Math.max(1, paragraph + 1), `"${fact.alias}" declares ${fact.value}${fact.unit}, text shows ${value}${fact.unit}`));
1248
+ break;
1249
+ }
1250
+ }
1251
+ return results;
1252
+ };
1043
1253
  const pleasureDensity = (ctx) => {
1044
1254
  const { min, keywords, window } = ctx.rulepack.semantics.pleasure_density;
1045
1255
  if (keywords.length === 0) return [];
@@ -1238,6 +1448,9 @@ const BUILTIN_RULES = {
1238
1448
  [SEMANTIC_RULES.PLEASURE_DENSITY]: pleasureDensity,
1239
1449
  [SEMANTIC_RULES.SENSORY_DENSITY]: sensoryDensity,
1240
1450
  [SEMANTIC_RULES.WATER_WORDS]: waterWords,
1451
+ [SEMANTIC_RULES.DIALOGUE_RATIO]: dialogueRatio,
1452
+ [SEMANTIC_RULES.NUMERIC_CONTRADICTION]: numericContradiction,
1453
+ [SEMANTIC_RULES.NUMERIC_FACT_MISMATCH]: numericFactMismatch,
1241
1454
  [CROSS_RULES.DUPLICATE_PARAGRAPH]: duplicateParagraph,
1242
1455
  [CROSS_RULES.FORESHADOW_DORMANT]: foreshadowDormant,
1243
1456
  [CROSS_RULES.FS_REVISITED_TOO_SOON]: revisitedTooSoon,
@@ -1455,6 +1668,7 @@ const SCHEMAS = {
1455
1668
  rounds: req("object"),
1456
1669
  briefs: req("object"),
1457
1670
  blocking: req("array"),
1671
+ draft: f("object"),
1458
1672
  review_digest: f("string"),
1459
1673
  last_action: f("any")
1460
1674
  },
@@ -1510,6 +1724,7 @@ const SCHEMAS = {
1510
1724
  content_hash: req("string"),
1511
1725
  machine: req("object"),
1512
1726
  semantic: req("object"),
1727
+ acknowledged: f("array"),
1513
1728
  verdict: req("string", { enum: ["pass", "fail"] })
1514
1729
  },
1515
1730
  worldPlan: {
@@ -1546,6 +1761,7 @@ const SCHEMAS = {
1546
1761
  characters: req("array"),
1547
1762
  glossary: req("array"),
1548
1763
  backstory: req("array"),
1764
+ facts: f("array"),
1549
1765
  themes: req("array")
1550
1766
  }
1551
1767
  };
@@ -1625,6 +1841,26 @@ const validateNested = (kind, record) => {
1625
1841
  if (targets.total_chapters !== null) integerAt(kind, targets.total_chapters, "targets.total_chapters");
1626
1842
  return;
1627
1843
  }
1844
+ case "pipeline": {
1845
+ const rounds = objectAt(kind, record.rounds, "rounds");
1846
+ for (const key of [
1847
+ "review",
1848
+ "revise",
1849
+ "check"
1850
+ ]) integerAt(kind, rounds[key], `rounds.${key}`);
1851
+ if (rounds.rollbacks !== void 0) integerAt(kind, rounds.rollbacks, "rounds.rollbacks");
1852
+ if (record.draft !== void 0) {
1853
+ const draft = objectAt(kind, record.draft, "draft");
1854
+ integerAt(kind, draft.chapter, "draft.chapter");
1855
+ stringAt(kind, draft.title, "draft.title");
1856
+ stringAt(kind, draft.scene_type, "draft.scene_type");
1857
+ stringAt(kind, draft.narrator, "draft.narrator");
1858
+ stringAt(kind, draft.content_hash, "draft.content_hash");
1859
+ stringAt(kind, draft.updated_at, "draft.updated_at");
1860
+ validateSummary(kind, draft.summary, "draft.summary");
1861
+ }
1862
+ return;
1863
+ }
1628
1864
  case "chapters":
1629
1865
  validateRecords(kind, record.chapters, "chapters", (row, path) => {
1630
1866
  integerAt(kind, row.num, `${path}.num`);
@@ -1641,6 +1877,17 @@ const validateNested = (kind, record) => {
1641
1877
  validateSummary(kind, row.summary, `${path}.summary`);
1642
1878
  if (row.assigned_ids !== void 0) objectAt(kind, row.assigned_ids, `${path}.assigned_ids`);
1643
1879
  optionalString(kind, row.archive_digest, `${path}.archive_digest`);
1880
+ optionalString(kind, row.final_check, `${path}.final_check`);
1881
+ optionalString(kind, row.archived_at, `${path}.archived_at`);
1882
+ if (row.rounds !== void 0) {
1883
+ const rounds = objectAt(kind, row.rounds, `${path}.rounds`);
1884
+ for (const key of [
1885
+ "review",
1886
+ "revise",
1887
+ "check"
1888
+ ]) integerAt(kind, rounds[key], `${path}.rounds.${key}`);
1889
+ if (rounds.rollbacks !== void 0) integerAt(kind, rounds.rollbacks, `${path}.rounds.rollbacks`);
1890
+ }
1644
1891
  });
1645
1892
  return;
1646
1893
  case "characters":
@@ -1699,6 +1946,7 @@ const validateNested = (kind, record) => {
1699
1946
  integerAt(kind, row.last_touched, `${path}.last_touched`);
1700
1947
  stringAt(kind, row.status, `${path}.status`);
1701
1948
  stringAt(kind, row.description, `${path}.description`);
1949
+ optionalString(kind, row.anchor, `${path}.anchor`);
1702
1950
  listAt(kind, row.advance_log, `${path}.advance_log`);
1703
1951
  });
1704
1952
  return;
@@ -1982,6 +2230,61 @@ var FsStore = class {
1982
2230
  throw error;
1983
2231
  }
1984
2232
  }
2233
+ /**
2234
+ * Directory entries without following symlinks. Used by bounded workspace
2235
+ * discovery so a symlinked child is never mistaken for a project.
2236
+ */
2237
+ async entries(relativeDir) {
2238
+ try {
2239
+ const absolute = relativeDir === "." ? resolve(this.root) : await this.safePath(relativeDir);
2240
+ return (await promises.readdir(absolute, { withFileTypes: true })).map((entry) => ({
2241
+ name: entry.name,
2242
+ directory: entry.isDirectory(),
2243
+ symbolicLink: entry.isSymbolicLink()
2244
+ })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
2245
+ } catch (error) {
2246
+ if (isMissing(error)) return [];
2247
+ throw error;
2248
+ }
2249
+ }
2250
+ /**
2251
+ * Metadata for a path without following the final symlink. A scandir probe
2252
+ * only needs to know that a candidate is a real directory; it must not read
2253
+ * through a link to decide that.
2254
+ */
2255
+ async lstat(relativePath) {
2256
+ try {
2257
+ const info = await promises.lstat(relativePath === "." ? resolve(this.root) : this.path(relativePath));
2258
+ return {
2259
+ directory: info.isDirectory(),
2260
+ symbolicLink: info.isSymbolicLink(),
2261
+ file: info.isFile()
2262
+ };
2263
+ } catch (error) {
2264
+ if (isMissing(error)) return null;
2265
+ throw error;
2266
+ }
2267
+ }
2268
+ /**
2269
+ * Follow a project-root symlink when classifying the root itself. Child
2270
+ * discovery still uses lstat/entries, so a symlinked *subdirectory* remains
2271
+ * out of workspace scans. The device/inode pair lets a transaction detect a
2272
+ * root that was deleted and recreated under the same path.
2273
+ */
2274
+ async statRoot() {
2275
+ try {
2276
+ const info = await promises.stat(resolve(this.root));
2277
+ return {
2278
+ directory: info.isDirectory(),
2279
+ dev: info.dev,
2280
+ ino: info.ino,
2281
+ birthtimeMs: info.birthtimeMs
2282
+ };
2283
+ } catch (error) {
2284
+ if (isMissing(error)) return null;
2285
+ throw error;
2286
+ }
2287
+ }
1985
2288
  async readText(relativePath) {
1986
2289
  try {
1987
2290
  return await promises.readFile(await this.safePath(relativePath), "utf8");
@@ -2002,10 +2305,33 @@ var FsStore = class {
2002
2305
  }
2003
2306
  return narrow(kind, (typeof parsed === "object" && parsed !== null && typeof parsed.schema_version === "number" ? parsed.schema_version : 1) < 1 ? migrateDocument(parsed, MIGRATIONS, 1) : parsed);
2004
2307
  }
2308
+ /**
2309
+ * Create missing directories below the store root without ever recreating the
2310
+ * root itself. `mkdir(..., {recursive:true})` silently recreates a deleted
2311
+ * project directory; walking one segment at a time makes the first missing
2312
+ * root fail with ENOENT and keeps deleted projects deleted.
2313
+ */
2314
+ async ensureParent(absolutePath) {
2315
+ const root = resolve(this.root);
2316
+ const parent = dirname(absolutePath);
2317
+ const rel = relative(root, parent);
2318
+ if (rel === "" || rel === ".") return;
2319
+ let current = root;
2320
+ for (const segment of rel.split(sep)) {
2321
+ if (segment === "") continue;
2322
+ current = join(current, segment);
2323
+ try {
2324
+ await promises.mkdir(current);
2325
+ } catch (error) {
2326
+ if (error.code === "EEXIST") continue;
2327
+ throw error;
2328
+ }
2329
+ }
2330
+ }
2005
2331
  /** Write text atomically: temp file, fsync, rename, directory fsync. */
2006
2332
  async writeText(relativePath, text) {
2007
2333
  const absolute = await this.safePath(relativePath);
2008
- await promises.mkdir(dirname(absolute), { recursive: true });
2334
+ await this.ensureParent(absolute);
2009
2335
  const temp = `${absolute}.${randomUUID()}.tmp`;
2010
2336
  try {
2011
2337
  const handle = await promises.open(temp, "w");
@@ -2042,7 +2368,7 @@ var FsStore = class {
2042
2368
  /** Raw append used only by the journal (JSONL is append-only by design). */
2043
2369
  async appendText(relativePath, text) {
2044
2370
  const absolute = await this.safePath(relativePath);
2045
- await promises.mkdir(dirname(absolute), { recursive: true });
2371
+ await this.ensureParent(absolute);
2046
2372
  const handle = await promises.open(absolute, "a+");
2047
2373
  try {
2048
2374
  const info = await handle.stat();
@@ -2069,7 +2395,7 @@ var FsStore = class {
2069
2395
  }
2070
2396
  async writeBytes(relativePath, bytes) {
2071
2397
  const absolute = await this.safePath(relativePath);
2072
- await promises.mkdir(dirname(absolute), { recursive: true });
2398
+ await this.ensureParent(absolute);
2073
2399
  const temp = `${absolute}.${randomUUID()}.tmp`;
2074
2400
  try {
2075
2401
  const handle = await promises.open(temp, "w");
@@ -2153,17 +2479,29 @@ async function readJournal(store) {
2153
2479
  status: raw.status
2154
2480
  };
2155
2481
  if (typeof raw.reason === "string") record.reason = raw.reason;
2482
+ if (typeof raw.code === "string") record.code = raw.code;
2483
+ if (typeof raw.hint === "string") record.hint = raw.hint;
2484
+ if (typeof raw.stage === "string") record.stage = raw.stage;
2485
+ if (raw.attempt === true) record.attempt = true;
2486
+ if (Array.isArray(raw.blocking)) record.blocking = raw.blocking.map((item) => typeof item === "object" && item !== null ? item : null).filter((item) => item !== null);
2156
2487
  if (typeof raw.hashes === "object" && raw.hashes !== null && !Array.isArray(raw.hashes)) record.hashes = Object.fromEntries(Object.entries(raw.hashes).filter((entry) => typeof entry[1] === "string"));
2157
2488
  if (typeof raw.before === "object" && raw.before !== null && !Array.isArray(raw.before)) record.before = Object.fromEntries(Object.entries(raw.before).filter((entry) => entry[1] === null || typeof entry[1] === "string"));
2158
2489
  records.push(record);
2159
2490
  if (record.status !== "pending") rows.push({
2491
+ seq: rows.length + 1,
2160
2492
  at: record.at,
2161
2493
  op: record.op,
2162
2494
  chapter: record.chapter,
2163
2495
  revision: record.revision,
2164
2496
  txid: record.txid,
2165
2497
  status: record.status,
2166
- files: record.files
2498
+ files: record.files,
2499
+ ...record.code === void 0 ? {} : { code: record.code },
2500
+ ...record.reason === void 0 ? {} : { reason: record.reason },
2501
+ ...record.hint === void 0 ? {} : { hint: record.hint },
2502
+ ...record.stage === void 0 ? {} : { stage: record.stage },
2503
+ ...record.attempt === true ? { attempt: true } : {},
2504
+ ...record.blocking === void 0 ? {} : { blocking: record.blocking }
2167
2505
  });
2168
2506
  } catch {
2169
2507
  malformed += 1;
@@ -2187,7 +2525,7 @@ function assertJournalHealthy(malformed) {
2187
2525
  async function reconcileJournal(store) {
2188
2526
  const { records, malformed } = await readJournal(store);
2189
2527
  const expected = /* @__PURE__ */ new Map();
2190
- const tracked = (path) => (path === "novel.yaml" || path.startsWith("state/") || path === "outline/outline.yaml" || path === "outline/worldbook.yaml") && path !== "state/archive-digests.json";
2528
+ const tracked = (path) => (path === "novel.yaml" || path.startsWith("state/") || path === "outline/outline.yaml" || path === "outline/worldbook.yaml") && path !== "state/archive-digests.json" && path !== "state/briefs.yaml";
2191
2529
  for (const record of records) {
2192
2530
  if (record.status !== "committed" || record.hashes === void 0) continue;
2193
2531
  for (const [path, hash] of Object.entries(record.hashes)) {
@@ -2254,6 +2592,7 @@ async function recoverPending(store) {
2254
2592
  await appendJournal(store, {
2255
2593
  ...last,
2256
2594
  status: "aborted",
2595
+ code: ERROR_CODES.TXN_ABORTED,
2257
2596
  reason: "recovered: the process stopped before the transaction renamed a target"
2258
2597
  });
2259
2598
  await store.removeTree(joinRel(STAGING_DIR, last.txid)).catch(() => void 0);
@@ -2480,6 +2819,7 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2480
2819
  // src/store/backup.ts
2481
2820
  /** Marks a directory this engine owns; user files under backups/ are never pruned. */
2482
2821
  const BACKUP_MARKER = ".engine-backup";
2822
+ const BACKUP_MANIFEST = "manifest.json";
2483
2823
  /** Backup transaction directories, newest first by filesystem mtime. */
2484
2824
  async function listBackups(store) {
2485
2825
  const entries = [];
@@ -2487,13 +2827,51 @@ async function listBackups(store) {
2487
2827
  const info = await store.stat(joinRel(BACKUP_DIR, name));
2488
2828
  if (info === null || !info.isDirectory) continue;
2489
2829
  if (!await store.exists(joinRel("backups", name, ".engine-backup"))) continue;
2830
+ const raw = await store.readText(joinRel(BACKUP_DIR, name, BACKUP_MANIFEST));
2831
+ let manifest = null;
2832
+ if (raw !== null) try {
2833
+ manifest = JSON.parse(raw);
2834
+ } catch {
2835
+ manifest = null;
2836
+ }
2837
+ const fileNames = Array.isArray(manifest?.files) ? manifest.files.filter((file) => typeof file === "string") : [];
2490
2838
  entries.push({
2491
2839
  name,
2492
- mtimeMs: info.mtimeMs
2840
+ mtimeMs: info.mtimeMs,
2841
+ txid: typeof manifest?.txid === "string" ? manifest.txid : name,
2842
+ op: typeof manifest?.op === "string" ? manifest.op : null,
2843
+ chapter: typeof manifest?.chapter === "number" ? manifest.chapter : null,
2844
+ revision: typeof manifest?.revision === "number" ? manifest.revision : null,
2845
+ at: typeof manifest?.at === "string" ? manifest.at : null,
2846
+ reason: typeof manifest?.reason === "string" ? manifest.reason : null,
2847
+ files: fileNames
2493
2848
  });
2494
2849
  }
2495
2850
  return entries.sort((left, right) => right.mtimeMs - left.mtimeMs);
2496
2851
  }
2852
+ /** Read one manifest, refusing a directory this engine did not create. */
2853
+ async function readBackupManifest(store, name) {
2854
+ if (!/^[0-9a-f-]{36}$/i.test(name)) return null;
2855
+ if (!await store.exists(joinRel("backups", name, ".engine-backup"))) return null;
2856
+ const raw = await store.readText(joinRel(BACKUP_DIR, name, BACKUP_MANIFEST));
2857
+ if (raw === null) return null;
2858
+ try {
2859
+ const value = JSON.parse(raw);
2860
+ if (value.schema_version !== 1 || typeof value.txid !== "string" || typeof value.op !== "string" || typeof value.chapter !== "number" || typeof value.revision !== "number" || typeof value.at !== "string" || !Array.isArray(value.files)) return null;
2861
+ return {
2862
+ schema_version: 1,
2863
+ txid: value.txid,
2864
+ op: value.op,
2865
+ chapter: value.chapter,
2866
+ revision: value.revision,
2867
+ at: value.at,
2868
+ ...typeof value.reason === "string" ? { reason: value.reason } : {},
2869
+ files: value.files.filter((file) => typeof file === "string")
2870
+ };
2871
+ } catch {
2872
+ return null;
2873
+ }
2874
+ }
2497
2875
  /**
2498
2876
  * Keep the newest `keep` backup directories. Only directories directly under
2499
2877
  * `backups/` are considered, and only directories this engine created are
@@ -2557,8 +2935,37 @@ async function runTransaction(store, input) {
2557
2935
  let journaledCommitted = false;
2558
2936
  let backupMarked = false;
2559
2937
  let backupComplete = false;
2938
+ let rootDisappeared = false;
2939
+ let rootIdentity = null;
2940
+ const rootKey = (info) => `${info.dev}:${info.ino}:${info.birthtimeMs}`;
2941
+ const rootUnavailable = async () => {
2942
+ try {
2943
+ const info = await store.statRoot();
2944
+ return info === null || !info.directory || rootIdentity !== null && rootKey(info) !== rootIdentity;
2945
+ } catch {
2946
+ return false;
2947
+ }
2948
+ };
2949
+ const assertRootPresent = async () => {
2950
+ const info = await store.statRoot();
2951
+ if (info === null || !info.directory) {
2952
+ rootDisappeared = true;
2953
+ throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the project root disappeared during the transaction", "restore the directory/mount and retry; the engine will not recreate a deleted project");
2954
+ }
2955
+ const key = rootKey(info);
2956
+ if (rootIdentity === null) {
2957
+ rootIdentity = key;
2958
+ return;
2959
+ }
2960
+ if (key !== rootIdentity) {
2961
+ rootDisappeared = true;
2962
+ throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the project root was replaced during the transaction", "restore the original directory/mount and retry; the engine will not write into a replacement project");
2963
+ }
2964
+ };
2560
2965
  try {
2966
+ await assertRootPresent();
2561
2967
  for (const file of input.files) {
2968
+ await assertRootPresent();
2562
2969
  const target = joinRel(staging, file.path);
2563
2970
  if (file.bytes !== void 0) await store.writeBytes(target, file.bytes);
2564
2971
  else await store.writeText(target, file.text ?? "");
@@ -2571,6 +2978,7 @@ async function runTransaction(store, input) {
2571
2978
  if (sha256(stagedBytes.toString("base64")) !== expected.get(file.path)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `staged copy of ${file.path} failed verification`);
2572
2979
  }
2573
2980
  if (input.afterVerify !== void 0) await input.afterVerify();
2981
+ await assertRootPresent();
2574
2982
  for (const file of input.files) before.set(file.path, await store.readBytes(file.path));
2575
2983
  for (const file of input.files) {
2576
2984
  const existing = before.get(file.path);
@@ -2585,6 +2993,16 @@ async function runTransaction(store, input) {
2585
2993
  await store.writeBytes(joinRel(stagingBackup, file.path), existing);
2586
2994
  }
2587
2995
  await store.writeText(joinRel(stagingBackup, BACKUP_MARKER), "1");
2996
+ await store.writeText(joinRel(stagingBackup, BACKUP_MANIFEST), `${JSON.stringify({
2997
+ schema_version: 1,
2998
+ txid: input.txid,
2999
+ op: input.op,
3000
+ chapter: input.chapter,
3001
+ revision: input.revision,
3002
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3003
+ ...input.reason === void 0 ? {} : { reason: input.reason },
3004
+ files: input.files.filter((file) => before.get(file.path) !== null && before.get(file.path) !== void 0).map((file) => file.path)
3005
+ }, null, 2)}\n`);
2588
3006
  const targetBackup = store.path(backupDir(input.txid));
2589
3007
  await promises.mkdir(dirname(targetBackup), { recursive: true });
2590
3008
  await promises.rename(store.path(stagingBackup), targetBackup);
@@ -2592,6 +3010,7 @@ async function runTransaction(store, input) {
2592
3010
  backupMarked = true;
2593
3011
  backupComplete = true;
2594
3012
  }
3013
+ await assertRootPresent();
2595
3014
  const beforeHashes = Object.fromEntries(input.files.map((file) => {
2596
3015
  const bytes = before.get(file.path);
2597
3016
  return [file.path, bytes === null || bytes === void 0 ? null : sha256(Buffer.from(bytes).toString("base64"))];
@@ -2609,6 +3028,7 @@ async function runTransaction(store, input) {
2609
3028
  ...input.reason === void 0 ? {} : { reason: input.reason }
2610
3029
  });
2611
3030
  for (const [index, file] of input.files.entries()) {
3031
+ await assertRootPresent();
2612
3032
  const from = await store.safePath(joinRel(staging, file.path));
2613
3033
  const to = await store.safePath(file.path);
2614
3034
  await promises.mkdir(dirname(to), { recursive: true });
@@ -2617,6 +3037,7 @@ async function runTransaction(store, input) {
2617
3037
  renamed.push(file.path);
2618
3038
  if (input.afterRename !== void 0) await input.afterRename(file.path, index);
2619
3039
  }
3040
+ await assertRootPresent();
2620
3041
  for (const directory of targetDirectories) await syncDirectory(directory);
2621
3042
  for (const file of input.files) if (await store.hashBytes(file.path) !== expected.get(file.path)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `readback of ${file.path} does not match what was staged`, "the file on disk was modified by another process during the commit");
2622
3043
  await appendJournal(store, {
@@ -2638,13 +3059,14 @@ async function runTransaction(store, input) {
2638
3059
  force: true
2639
3060
  }).catch(() => void 0);
2640
3061
  } catch (error) {
2641
- const rollbackProblem = await restoreTargets(store, renamed, before);
2642
- if (rollbackProblem === null) await promises.rm(store.path(staging), {
3062
+ if (!rootDisappeared) rootDisappeared = await rootUnavailable();
3063
+ const rollbackProblem = rootDisappeared ? null : await restoreTargets(store, renamed, before);
3064
+ if (!rootDisappeared && rollbackProblem === null) await promises.rm(store.path(staging), {
2643
3065
  recursive: true,
2644
3066
  force: true
2645
3067
  }).catch(() => void 0);
2646
- if (input.backup && (!backupMarked || !backupComplete && renamed.length === 0)) await store.removeTree(backupDir(input.txid)).catch(() => void 0);
2647
- if (!journaledCommitted) await appendJournal(store, {
3068
+ if (!rootDisappeared && input.backup && (!backupMarked || !backupComplete && renamed.length === 0)) await store.removeTree(backupDir(input.txid)).catch(() => void 0);
3069
+ if (!journaledCommitted && !rootDisappeared) await appendJournal(store, {
2648
3070
  txid: input.txid,
2649
3071
  at: (/* @__PURE__ */ new Date()).toISOString(),
2650
3072
  op: input.op,
@@ -2652,6 +3074,7 @@ async function runTransaction(store, input) {
2652
3074
  revision: input.revision,
2653
3075
  files: input.files.map((file) => file.path),
2654
3076
  status: "aborted",
3077
+ code: error instanceof EngineError ? error.code : ERROR_CODES.TXN_ABORTED,
2655
3078
  reason: error instanceof Error ? error.message : String(error)
2656
3079
  }, { repairTrailing: true }).catch(() => void 0);
2657
3080
  const suffix = rollbackProblem === null ? "" : `; compensation was incomplete: ${rollbackProblem}; do not retry until the files are restored`;
@@ -2663,7 +3086,7 @@ async function runTransaction(store, input) {
2663
3086
  }
2664
3087
  }
2665
3088
  // src/store/registry.ts
2666
- const empty = () => ({
3089
+ const empty$1 = () => ({
2667
3090
  schema_version: 1,
2668
3091
  active: null,
2669
3092
  projects: []
@@ -2680,7 +3103,7 @@ var ProjectRegistry = class {
2680
3103
  /** Read the registry; a missing file is an empty registry, a corrupt one is a fault. */
2681
3104
  async read() {
2682
3105
  const text = await this.store.readText(REGISTRY_FILE);
2683
- if (text === null) return empty();
3106
+ if (text === null) return empty$1();
2684
3107
  let raw;
2685
3108
  try {
2686
3109
  raw = parse(text);
@@ -2690,20 +3113,45 @@ var ProjectRegistry = class {
2690
3113
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not an object`);
2691
3114
  const record = raw;
2692
3115
  if (record.schema_version !== 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} has an unsupported schema_version`);
3116
+ const projects = [];
3117
+ const indexes = /* @__PURE__ */ new Map();
3118
+ if (record.projects !== void 0 && !Array.isArray(record.projects)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE}.projects must be an array`, "fix or delete the registry file; projects on disk are unaffected");
3119
+ if (Array.isArray(record.projects)) for (const value of record.projects) {
3120
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
3121
+ const entry = value;
3122
+ if (typeof entry.root !== "string" || entry.root === "") continue;
3123
+ if (typeof entry.title !== "string") continue;
3124
+ const normalized = {
3125
+ root: resolve(entry.root),
3126
+ title: entry.title,
3127
+ ...typeof entry.created_at === "string" && entry.created_at !== "" ? { created_at: entry.created_at } : {},
3128
+ updated_at: typeof entry.updated_at === "string" ? entry.updated_at : ""
3129
+ };
3130
+ const existing = indexes.get(normalized.root);
3131
+ if (existing === void 0) {
3132
+ indexes.set(normalized.root, projects.length);
3133
+ projects.push(normalized);
3134
+ } else projects[existing] = normalized;
3135
+ }
2693
3136
  return {
2694
3137
  schema_version: 1,
2695
- active: typeof record.active === "string" ? record.active : null,
2696
- projects: Array.isArray(record.projects) ? record.projects.filter((entry) => typeof entry === "object" && entry !== null && typeof entry.root === "string" && typeof entry.title === "string") : []
3138
+ active: typeof record.active === "string" && record.active.trim() !== "" ? resolve(record.active) : null,
3139
+ projects
2697
3140
  };
2698
3141
  }
2699
3142
  /** Insert or update one project row. */
2700
3143
  async register(entry) {
2701
3144
  const document = await this.read();
3145
+ const root = resolve(entry.root);
3146
+ const previous = document.projects.find((candidate) => candidate.root === root);
3147
+ const created_at = entry.created_at ?? previous?.created_at;
2702
3148
  const row = {
2703
- ...entry,
3149
+ root,
3150
+ title: entry.title,
3151
+ ...created_at === void 0 || created_at === "" ? {} : { created_at },
2704
3152
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
2705
3153
  };
2706
- const rows = document.projects.filter((candidate) => candidate.root !== entry.root);
3154
+ const rows = document.projects.filter((candidate) => candidate.root !== root);
2707
3155
  rows.push(row);
2708
3156
  const next = {
2709
3157
  ...document,
@@ -2716,7 +3164,7 @@ var ProjectRegistry = class {
2716
3164
  async setActive(root) {
2717
3165
  const next = {
2718
3166
  ...await this.read(),
2719
- active: root
3167
+ active: resolve(root)
2720
3168
  };
2721
3169
  await this.write(next);
2722
3170
  return next;
@@ -2724,10 +3172,11 @@ var ProjectRegistry = class {
2724
3172
  /** Drop a row; the project on disk is untouched. */
2725
3173
  async forget(root) {
2726
3174
  const document = await this.read();
3175
+ const target = resolve(root);
2727
3176
  const next = {
2728
3177
  ...document,
2729
- projects: document.projects.filter((entry) => entry.root !== root),
2730
- active: document.active === root ? null : document.active
3178
+ projects: document.projects.filter((entry) => entry.root !== target),
3179
+ active: document.active === target ? null : document.active
2731
3180
  };
2732
3181
  await this.write(next);
2733
3182
  return next;
@@ -2736,6 +3185,132 @@ var ProjectRegistry = class {
2736
3185
  await this.store.writeText(REGISTRY_FILE, stringify(document, { lineWidth: 0 }));
2737
3186
  }
2738
3187
  };
3188
+ // src/store/bindings.ts
3189
+ const empty = () => ({
3190
+ schema_version: 1,
3191
+ bindings: []
3192
+ });
3193
+ const sources = /* @__PURE__ */ new Set([
3194
+ "explicit",
3195
+ "init",
3196
+ "command"
3197
+ ]);
3198
+ const row = (value) => {
3199
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
3200
+ const record = value;
3201
+ if (typeof record.session_id !== "string" || record.session_id === "") return null;
3202
+ if (typeof record.root !== "string" || record.root === "") return null;
3203
+ const source = typeof record.source === "string" && sources.has(record.source) ? record.source : "explicit";
3204
+ return {
3205
+ session_id: record.session_id,
3206
+ root: resolve(record.root),
3207
+ title: typeof record.title === "string" ? record.title : "",
3208
+ project_created_at: typeof record.project_created_at === "string" ? record.project_created_at : "",
3209
+ bound_at: typeof record.bound_at === "string" ? record.bound_at : "",
3210
+ source
3211
+ };
3212
+ };
3213
+ /**
3214
+ * Session-scoped project choices. This file is convenience state, never project
3215
+ * truth: deleting it loses no book, and a corrupt file is a hard read error
3216
+ * rather than a silent "no bindings" that could write into another project.
3217
+ */
3218
+ var SessionBindings = class {
3219
+ store;
3220
+ options;
3221
+ constructor(store, options = {}) {
3222
+ this.store = store;
3223
+ this.options = options;
3224
+ }
3225
+ async read() {
3226
+ const text = await this.store.readText(SESSION_BINDINGS_FILE);
3227
+ if (text === null) return empty();
3228
+ let raw;
3229
+ try {
3230
+ raw = parse(text);
3231
+ } catch (error) {
3232
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${SESSION_BINDINGS_FILE} is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, "fix or delete the session binding file; projects on disk are unaffected", { cause: error });
3233
+ }
3234
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${SESSION_BINDINGS_FILE} is not an object`);
3235
+ const record = raw;
3236
+ if (record.schema_version !== 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${SESSION_BINDINGS_FILE} has an unsupported schema_version`);
3237
+ if (record.bindings !== void 0 && !Array.isArray(record.bindings)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${SESSION_BINDINGS_FILE}.bindings must be an array`, "fix or delete the session binding file; projects on disk are unaffected");
3238
+ const bindings = [];
3239
+ const indexes = /* @__PURE__ */ new Map();
3240
+ for (const candidate of record.bindings ?? []) {
3241
+ const parsed = row(candidate);
3242
+ if (parsed === null) continue;
3243
+ const existing = indexes.get(parsed.session_id);
3244
+ if (existing === void 0) {
3245
+ indexes.set(parsed.session_id, bindings.length);
3246
+ bindings.push(parsed);
3247
+ } else bindings[existing] = parsed;
3248
+ }
3249
+ return {
3250
+ schema_version: 1,
3251
+ bindings
3252
+ };
3253
+ }
3254
+ async get(sessionId) {
3255
+ return (await this.read()).bindings.find((entry) => entry.session_id === sessionId);
3256
+ }
3257
+ async list() {
3258
+ return (await this.read()).bindings;
3259
+ }
3260
+ /**
3261
+ * Insert or update one session's choice. A byte-for-byte equivalent binding
3262
+ * does not touch the file, so a read-only operation can call this safely.
3263
+ */
3264
+ async bind(input) {
3265
+ const document = await this.read();
3266
+ const root = resolve(input.root);
3267
+ const existing = document.bindings.find((entry) => entry.session_id === input.session_id);
3268
+ if (existing !== void 0 && existing.root === root && existing.title === input.title && existing.project_created_at === input.project_created_at) return {
3269
+ binding: existing,
3270
+ document,
3271
+ changed: false
3272
+ };
3273
+ const binding = {
3274
+ session_id: input.session_id,
3275
+ root,
3276
+ title: input.title,
3277
+ project_created_at: input.project_created_at,
3278
+ bound_at: (this.options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
3279
+ source: input.source
3280
+ };
3281
+ const next = {
3282
+ schema_version: 1,
3283
+ bindings: existing === void 0 ? [...document.bindings, binding] : document.bindings.map((entry) => entry.session_id === input.session_id ? binding : entry)
3284
+ };
3285
+ await this.write(next);
3286
+ return {
3287
+ binding,
3288
+ document: next,
3289
+ changed: true
3290
+ };
3291
+ }
3292
+ /** Remove one session binding; a missing row is a no-op. */
3293
+ async clear(sessionId) {
3294
+ const document = await this.read();
3295
+ const bindings = document.bindings.filter((entry) => entry.session_id !== sessionId);
3296
+ if (bindings.length === document.bindings.length) return {
3297
+ document,
3298
+ changed: false
3299
+ };
3300
+ const next = {
3301
+ schema_version: 1,
3302
+ bindings
3303
+ };
3304
+ await this.write(next);
3305
+ return {
3306
+ document: next,
3307
+ changed: true
3308
+ };
3309
+ }
3310
+ async write(document) {
3311
+ await this.store.writeText(SESSION_BINDINGS_FILE, stringify(document, { lineWidth: 0 }));
3312
+ }
3313
+ };
2739
3314
  // src/assets.ts
2740
3315
  /**
2741
3316
  * The only reader of bundled assets. `apply()` performs no IO; the engine and
@@ -3124,6 +3699,7 @@ function emptyWorldbook(now) {
3124
3699
  characters: [],
3125
3700
  glossary: [],
3126
3701
  backstory: [],
3702
+ facts: [],
3127
3703
  themes: []
3128
3704
  };
3129
3705
  }
@@ -3132,15 +3708,28 @@ function outlineCard(document, chapter) {
3132
3708
  return document.chapters.find((card) => card.num === chapter) ?? null;
3133
3709
  }
3134
3710
  /** A compact progress view used by status, the panel and the report. */
3135
- function outlineSummary(document, archivedCount) {
3711
+ function outlineSummary(document, archivedCount, target = null, archivedNumbers) {
3136
3712
  const planned = document.chapters.filter((card) => card.status === "planned").length;
3137
3713
  const archived = document.chapters.filter((card) => card.status === "archived").length;
3714
+ const total = document.chapters.length;
3715
+ const missingCards = target === null ? 0 : Math.max(0, target - total);
3716
+ const plannedCoverage = target === null || target === 0 ? null : Math.min(1, total / target);
3717
+ const archivedCoverage = target === null || target === 0 ? null : Math.min(1, archived / target);
3718
+ const archivedSet = archivedNumbers === void 0 ? null : new Set(archivedNumbers);
3719
+ const matchedCards = archivedSet === null ? archived : document.chapters.filter((card) => card.status === "archived" && archivedSet.has(card.num)).length;
3720
+ const archivedDenominator = archivedSet === null ? archivedCount : archivedSet.size;
3721
+ const cardCoverage = archivedDenominator === 0 ? 1 : Math.min(1, matchedCards / archivedDenominator);
3138
3722
  return {
3139
- present: bookPlanPresent(document.book) || document.volumes.length > 0 || document.chapters.length > 0,
3140
- total: document.chapters.length,
3723
+ present: bookPlanPresent(document.book) || document.volumes.length > 0 || total > 0,
3724
+ target,
3725
+ total,
3141
3726
  planned,
3142
3727
  archived,
3143
- coverage: archivedCount === 0 ? 0 : Math.min(1, archived / archivedCount),
3728
+ missingCards,
3729
+ plannedCoverage,
3730
+ archivedCoverage,
3731
+ coverage: plannedCoverage ?? (archivedDenominator === 0 ? 0 : cardCoverage),
3732
+ cardCoverage,
3144
3733
  volumes: document.volumes.length
3145
3734
  };
3146
3735
  }
@@ -3469,6 +4058,7 @@ function mergeWorldbook(existing, patch, now) {
3469
4058
  characters: [...existing.characters],
3470
4059
  glossary: [...existing.glossary],
3471
4060
  backstory: [...existing.backstory],
4061
+ facts: [...existing.facts ?? []],
3472
4062
  themes: [...existing.themes]
3473
4063
  };
3474
4064
  if (patch.premise !== void 0) result.premise = optionalText(patch.premise, "premise", LIMITS.maxWorldbookTextChars);
@@ -3534,6 +4124,26 @@ function mergeWorldbook(existing, patch, now) {
3534
4124
  time: text$1(raw.time, `backstory[${index}].time`, 120),
3535
4125
  event: text$1(raw.event, `backstory[${index}].event`, LIMITS.maxWorldbookTextChars)
3536
4126
  }));
4127
+ if (patch.facts !== void 0) {
4128
+ result.facts = records(patch.facts, "facts").map((raw, index) => {
4129
+ const value = raw.value;
4130
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts[${index}].value must be a finite number`);
4131
+ const tolerance = raw.tolerance ?? 0;
4132
+ 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`);
4133
+ return {
4134
+ alias: text$1(raw.alias, `facts[${index}].alias`, 120),
4135
+ value,
4136
+ unit: text$1(raw.unit, `facts[${index}].unit`, 40),
4137
+ tolerance,
4138
+ ...raw.note === void 0 ? {} : { note: optionalText(raw.note, `facts[${index}].note`) }
4139
+ };
4140
+ });
4141
+ const seenFacts = /* @__PURE__ */ new Set();
4142
+ for (const fact of result.facts) {
4143
+ if (seenFacts.has(fact.alias)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts contains duplicate "${fact.alias}"`);
4144
+ seenFacts.add(fact.alias);
4145
+ }
4146
+ }
3537
4147
  return result;
3538
4148
  }
3539
4149
  /** Validate a worldbook loaded from disk. */
@@ -3552,6 +4162,7 @@ function validateWorldbookDocument(value, now) {
3552
4162
  "characters",
3553
4163
  "glossary",
3554
4164
  "backstory",
4165
+ "facts",
3555
4166
  "themes"
3556
4167
  ]);
3557
4168
  for (const key of Object.keys(value)) if (!allowedTopLevel.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/worldbook.yaml has unknown key "${key}"`);
@@ -3672,14 +4283,16 @@ function outlineDrift(document, records) {
3672
4283
  }
3673
4284
  /** Counts for status/panel/report; no IO. */
3674
4285
  function worldbookCounts(document) {
4286
+ const facts = document.facts?.length ?? 0;
3675
4287
  return {
3676
- present: document.premise !== "" || document.setting !== "" || document.themes.length > 0 || document.characters.length > 0 || document.locations.length > 0 || document.factions.length > 0 || document.rules.length > 0 || document.glossary.length > 0 || document.backstory.length > 0,
4288
+ present: document.premise !== "" || document.setting !== "" || document.themes.length > 0 || document.characters.length > 0 || document.locations.length > 0 || document.factions.length > 0 || document.rules.length > 0 || document.glossary.length > 0 || document.backstory.length > 0 || facts > 0,
3677
4289
  characters: document.characters.length,
3678
4290
  locations: document.locations.length,
3679
4291
  factions: document.factions.length,
3680
4292
  rules: document.rules.length,
3681
4293
  glossary: document.glossary.length,
3682
- backstory: document.backstory.length
4294
+ backstory: document.backstory.length,
4295
+ facts
3683
4296
  };
3684
4297
  }
3685
4298
  const pick = (values, mentioned, key, limit) => {
@@ -3728,6 +4341,13 @@ function worldbookBrief(document, mentioned, names) {
3728
4341
  backstory: document.backstory.slice(0, 3).map((entry) => ({
3729
4342
  time: entry.time,
3730
4343
  event: clip(entry.event, 160)
4344
+ })),
4345
+ facts: (document.facts ?? []).slice(0, 20).map((fact) => ({
4346
+ alias: clip(fact.alias, 120),
4347
+ value: fact.value,
4348
+ unit: clip(fact.unit, 40),
4349
+ tolerance: fact.tolerance ?? 0,
4350
+ ...fact.note === void 0 ? {} : { note: clip(fact.note, 160) }
3731
4351
  }))
3732
4352
  };
3733
4353
  }
@@ -4095,14 +4715,18 @@ function buildBookReport(input) {
4095
4715
  role: character.role,
4096
4716
  appearances: appearances.get(character.name) ?? 0,
4097
4717
  last_seen: Math.max(character.updated_at, lastAppearance.get(character.name) ?? 0)
4098
- })).sort((left, right) => right.appearances - left.appearances || right.last_seen - left.last_seen || left.name.localeCompare(right.name, "zh-CN")).slice(0, 10);
4718
+ })).sort((left, right) => right.appearances - left.appearances || right.last_seen - left.last_seen || compareText(left.name, right.name)).slice(0, 10);
4099
4719
  const latestChapter = records.length === 0 ? 0 : records[records.length - 1].num;
4100
4720
  const open = input.foreshadows.items.filter((item) => item.status !== "resolved");
4101
4721
  const dormant = open.filter((item) => item.status === "dormant");
4102
4722
  const overdue = open.filter((item) => latestChapter - item.last_touched >= input.foreshadowDormantAfter);
4103
4723
  const relationshipTypes = {};
4104
4724
  for (const relationship of input.relationships.items) relationshipTypes[relationship.type] = (relationshipTypes[relationship.type] ?? 0) + 1;
4105
- const outline = outlineSummary(input.outline, records.length);
4725
+ const outline = outlineSummary(input.outline, records.length, input.targetChapters, records.map((record) => record.num));
4726
+ const averageWords = records.length === 0 ? 0 : Math.round(words / records.length);
4727
+ const remainingChapters = input.targetChapters === null ? null : Math.max(0, input.targetChapters - records.length);
4728
+ const minimumRemaining = remainingChapters === null ? null : remainingChapters * input.project.targets.words_per_chapter[0];
4729
+ const averageRemaining = remainingChapters === null ? null : remainingChapters * (records.length === 0 ? input.project.targets.words_per_chapter[0] : averageWords);
4106
4730
  const drift = outlineDrift(input.outline, records);
4107
4731
  const volumeCounts = input.outline.volumes.map((volume) => {
4108
4732
  const cards = input.outline.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
@@ -4128,16 +4752,25 @@ function buildBookReport(input) {
4128
4752
  archived: records.length,
4129
4753
  target: input.targetChapters,
4130
4754
  words,
4131
- average_words: records.length === 0 ? 0 : Math.round(words / records.length),
4132
- percent: input.targetChapters === null || input.targetChapters === 0 ? 0 : Math.min(100, Math.round(records.length / input.targetChapters * 100)),
4133
- remaining_words: input.targetChapters === null ? null : Math.max(0, (input.targetChapters - records.length) * input.project.targets.words_per_chapter[0])
4755
+ average_words: averageWords,
4756
+ percent: input.targetChapters === null || input.targetChapters === 0 ? 0 : Math.min(100, Math.round(records.length / input.targetChapters * 1e3) / 10),
4757
+ remaining_words: averageRemaining,
4758
+ remaining_words_min: minimumRemaining,
4759
+ remaining_words_average: averageRemaining,
4760
+ remaining_words_basis: input.targetChapters === null ? "none" : records.length === 0 ? "minimum_target" : "actual_average"
4134
4761
  },
4135
4762
  outline: {
4136
4763
  present: outline.present,
4137
4764
  strict: input.outline.book.strict,
4765
+ target: outline.target,
4766
+ total: outline.total,
4138
4767
  planned: outline.planned,
4139
4768
  archived: outline.archived,
4769
+ missing_cards: outline.missingCards,
4770
+ planned_coverage: outline.plannedCoverage,
4771
+ archived_coverage: outline.archivedCoverage,
4140
4772
  coverage: outline.coverage,
4773
+ card_coverage: outline.cardCoverage,
4141
4774
  volumes: volumeCounts,
4142
4775
  drift
4143
4776
  },
@@ -4153,7 +4786,22 @@ function buildBookReport(input) {
4153
4786
  id,
4154
4787
  average: Math.round(value.total / value.samples * 10) / 10,
4155
4788
  samples: value.samples
4156
- })).sort((left, right) => left.id.localeCompare(right.id, "zh-CN"))
4789
+ })).sort((left, right) => compareText(left.id, right.id))
4790
+ },
4791
+ effort: {
4792
+ reviews: records.reduce((total, record) => total + (record.rounds?.review ?? 0), 0),
4793
+ revisions: records.reduce((total, record) => total + (record.rounds?.revise ?? 0), 0),
4794
+ checks: records.reduce((total, record) => total + (record.rounds?.check ?? 0), 0),
4795
+ rollbacks: records.reduce((total, record) => total + (record.rounds?.rollbacks ?? 0), 0),
4796
+ chapters: records.map((record) => ({
4797
+ num: record.num,
4798
+ reviews: record.rounds?.review ?? 0,
4799
+ revisions: record.rounds?.revise ?? 0,
4800
+ checks: record.rounds?.check ?? 0,
4801
+ rollbacks: record.rounds?.rollbacks ?? 0,
4802
+ final_check: record.final_check ?? null,
4803
+ archived_at: record.archived_at ?? null
4804
+ }))
4157
4805
  },
4158
4806
  pacing: {
4159
4807
  scene_types: sceneTypes,
@@ -4171,7 +4819,7 @@ function buildBookReport(input) {
4171
4819
  dormant: dormant.length,
4172
4820
  overdue: overdue.length,
4173
4821
  resolved: input.foreshadows.items.length - open.length,
4174
- oldest: [...open].sort((left, right) => left.last_touched - right.last_touched || left.id.localeCompare(right.id)).slice(0, 5).map((item) => ({
4822
+ oldest: [...open].sort((left, right) => left.last_touched - right.last_touched || compareText(left.id, right.id)).slice(0, 5).map((item) => ({
4175
4823
  id: item.id,
4176
4824
  title: item.title,
4177
4825
  last_touched: item.last_touched,
@@ -4455,6 +5103,10 @@ function searchSnapshot(input) {
4455
5103
  ...input.worldbook.backstory.map((entry) => ({
4456
5104
  title: entry.time,
4457
5105
  source: `${entry.time}\n${entry.event}`
5106
+ })),
5107
+ ...(input.worldbook.facts ?? []).map((fact) => ({
5108
+ title: fact.alias,
5109
+ source: `${fact.alias}\n${fact.value} ${fact.unit}${fact.tolerance === void 0 ? "" : ` ±${fact.tolerance}`}\n${fact.note ?? ""}`
4458
5110
  }))
4459
5111
  ];
4460
5112
  for (const entry of entries) {
@@ -4529,6 +5181,58 @@ function searchSnapshot(input) {
4529
5181
  results
4530
5182
  };
4531
5183
  }
5184
+ // src/engine/history.ts
5185
+ const PATTERN = /^ch(?<chapter>\d{3,})\.r(?<revision>\d+)\.(?<suffix>review\.yaml|check\.yaml|chapter\.yaml|md)$/;
5186
+ const kindOf = (directory, suffix) => {
5187
+ if (directory === "reviews") return suffix === "review.yaml";
5188
+ if (directory === "checks") return suffix === "check.yaml";
5189
+ if (directory === "plans") return suffix === "chapter.yaml";
5190
+ return suffix === "md";
5191
+ };
5192
+ /**
5193
+ * List prior versions, newest revision first. Files beyond the visible cap are
5194
+ * still on disk: history is evidence, not a cache, and this function never
5195
+ * deletes it.
5196
+ */
5197
+ async function listHistory(store, chapter) {
5198
+ const entries = [];
5199
+ for (const kind of HISTORY_KINDS) {
5200
+ const names = await store.list(joinRel(HISTORY_DIR, kind));
5201
+ for (const name of names) {
5202
+ const match = PATTERN.exec(name);
5203
+ if (match === null) continue;
5204
+ const suffix = match.groups.suffix;
5205
+ if (!kindOf(kind, suffix)) continue;
5206
+ const num = Number(match.groups.chapter);
5207
+ const revision = Number(match.groups.revision);
5208
+ if (chapter !== void 0 && num !== chapter) continue;
5209
+ const path = joinRel(HISTORY_DIR, kind, name);
5210
+ const text = await store.readText(path);
5211
+ entries.push({
5212
+ kind,
5213
+ chapter: num,
5214
+ revision,
5215
+ path,
5216
+ chars: text?.length ?? 0
5217
+ });
5218
+ }
5219
+ }
5220
+ return entries.sort((left, right) => left.chapter - right.chapter || right.revision - left.revision || compareText(left.kind, right.kind));
5221
+ }
5222
+ /** Read every prior version of one kind for a chapter, oldest first. */
5223
+ async function historyTexts(store, kind, chapter, entries) {
5224
+ const entriesForChapter = (entries ?? await listHistory(store, chapter)).filter((entry) => entry.kind === kind && entry.chapter === chapter);
5225
+ const result = [];
5226
+ for (const entry of entriesForChapter) {
5227
+ const text = await store.readText(entry.path);
5228
+ if (text !== null) result.push({
5229
+ path: entry.path,
5230
+ revision: entry.revision,
5231
+ text
5232
+ });
5233
+ }
5234
+ return result.sort((left, right) => left.revision - right.revision);
5235
+ }
4532
5236
  // src/engine/brief.ts
4533
5237
  const TITLES = {
4534
5238
  P1: "P1 世界观约束",
@@ -5285,8 +5989,12 @@ const requireReason = (value, where) => {
5285
5989
  if (value.action !== "no_change") return;
5286
5990
  if (typeof value.reason !== "string" || value.reason.trim() === "") fail$1(`${where} is no_change but has no reason`);
5287
5991
  };
5992
+ /** A one-character anchor is indistinguishable from a substring accident. */
5993
+ const ANCHOR_MIN_CHARS = 8;
5994
+ const anchorIsStrong = (value) => typeof value === "string" && Array.from(value.trim()).length >= ANCHOR_MIN_CHARS;
5288
5995
  const requireForeshadowFields = (value, where) => {
5289
5996
  if (value.title !== void 0 && typeof value.title !== "string") fail$1(`${where}.title must be a string`);
5997
+ if (value.anchor !== void 0 && !anchorIsStrong(value.anchor)) fail$1(`${where}.anchor must be a verbatim quote of at least ${ANCHOR_MIN_CHARS} characters`);
5290
5998
  if (value.description !== void 0 && typeof value.description !== "string") fail$1(`${where}.description must be a string`);
5291
5999
  if (value.note !== void 0 && typeof value.note !== "string") fail$1(`${where}.note must be a string`);
5292
6000
  if (value.new_info !== void 0 && typeof value.new_info !== "boolean") fail$1(`${where}.new_info must be a boolean`);
@@ -5297,6 +6005,32 @@ const appearanceOf = (text, name, aliases = []) => {
5297
6005
  return Array.from(source).slice(0, 120).join("");
5298
6006
  };
5299
6007
  const named = (value) => isRecord(value) && typeof value.name === "string";
6008
+ const ROLE_ALIASES = {
6009
+ 主角: "protagonist",
6010
+ 男主角: "protagonist",
6011
+ 女主: "protagonist",
6012
+ 女主角: "protagonist",
6013
+ 男主: "protagonist",
6014
+ 主人公: "protagonist",
6015
+ protagonist: "protagonist",
6016
+ 配角: "supporting",
6017
+ 重要配角: "supporting",
6018
+ 男二: "supporting",
6019
+ 女二: "supporting",
6020
+ supporting: "supporting",
6021
+ 反派: "antagonist",
6022
+ antagonist: "antagonist",
6023
+ 龙套: "extra",
6024
+ 路人: "extra",
6025
+ extra: "extra"
6026
+ };
6027
+ /** Keep one canonical role vocabulary instead of letting free text reach reports. */
6028
+ const normalizeRole = (value, fallback) => {
6029
+ const canonicalFallback = ROLE_ALIASES[fallback] ?? "extra";
6030
+ const trimmed = value?.trim() ?? "";
6031
+ if (trimmed === "") return canonicalFallback;
6032
+ return ROLE_ALIASES[trimmed] ?? canonicalFallback;
6033
+ };
5300
6034
  /**
5301
6035
  * Apply the archive reconciliation sheet. Every ledger entity the summary
5302
6036
  * mentions must be declared; unknown keys are refused instead of being
@@ -5358,9 +6092,10 @@ function applyStateDelta(input) {
5358
6092
  if (declared.action === "created") {
5359
6093
  if (existing !== void 0) fail$1(`character "${name}" already exists; use updated or no_change`);
5360
6094
  if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
6095
+ const hinted = normalizeRole(input.roleHints?.[name], name === narratorName || name === narrator ? "protagonist" : "extra");
5361
6096
  const created = {
5362
6097
  name,
5363
- role: declared.role ?? "extra",
6098
+ role: normalizeRole(declared.role, hinted),
5364
6099
  first_appearance: chapter,
5365
6100
  profile: declared.profile ?? {},
5366
6101
  current_state: declared.current_state ?? {},
@@ -5373,7 +6108,7 @@ function applyStateDelta(input) {
5373
6108
  }
5374
6109
  if (existing === void 0) fail$1(`character "${name}" does not exist; created must be used first`);
5375
6110
  if (declared.action === "updated") {
5376
- if (declared.role !== void 0) existing.role = declared.role;
6111
+ if (declared.role !== void 0) existing.role = normalizeRole(declared.role, existing.role);
5377
6112
  if (declared.profile !== void 0) existing.profile = {
5378
6113
  ...existing.profile,
5379
6114
  ...declared.profile
@@ -5542,11 +6277,15 @@ function applyStateDelta(input) {
5542
6277
  requireForeshadowFields(declared, `foreshadows.${plant.key}`);
5543
6278
  if (declared.action !== "created") fail$1(`foreshadows.${plant.key} is planted; its action must be created`);
5544
6279
  if (typeof plant.title !== "string" || plant.title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty summary title`);
5545
- if (!input.text.includes(plant.title)) fail$1(`planted foreshadow summary title "${plant.title}" does not appear in the manuscript`);
6280
+ if (plant.anchor !== void 0 && !anchorIsStrong(plant.anchor)) fail$1(`foreshadow "${plant.key}" anchor must be a verbatim quote of at least ${ANCHOR_MIN_CHARS} characters`);
5546
6281
  if (declared.title !== void 0 && typeof declared.title !== "string") fail$1(`foreshadows.${plant.key}.title must be a string`);
5547
6282
  const title = declared.title ?? plant.title;
5548
6283
  if (title.trim() === "") fail$1(`foreshadow "${plant.key}" has an empty title`);
5549
- if (!input.text.includes(title)) fail$1(`planted foreshadow "${title}" does not appear in the manuscript`);
6284
+ const anchor = declared.anchor ?? plant.anchor;
6285
+ if (anchor === void 0) {
6286
+ if (!input.text.includes(plant.title)) fail$1(`planted foreshadow summary title "${plant.title}" has no anchor and does not appear verbatim in the manuscript`);
6287
+ if (!input.text.includes(title)) fail$1(`planted foreshadow "${title}" has no anchor and does not appear verbatim in the manuscript; add a verbatim anchor or keep the title as text`);
6288
+ } else if (!input.text.includes(anchor)) fail$1(`planted foreshadow "${title}" anchor "${anchor}" does not appear verbatim in the manuscript`);
5550
6289
  const allocated = allocateIds("FS", foreshadows.next_seq, 1);
5551
6290
  const id = allocated.ids[0];
5552
6291
  if (byId.has(id)) fail$1(`foreshadows.next_seq is behind the ledger; ${id} already exists`);
@@ -5559,6 +6298,7 @@ function applyStateDelta(input) {
5559
6298
  status: "planted",
5560
6299
  resolved_at: null,
5561
6300
  description: declared.description ?? plant.description,
6301
+ ...anchor === void 0 ? {} : { anchor },
5562
6302
  advance_log: []
5563
6303
  };
5564
6304
  foreshadows.items.push(item);
@@ -5884,7 +6624,7 @@ const wordCount = (input) => {
5884
6624
  if (min <= 0) return [];
5885
6625
  const metrics = countWordMetrics(input.text);
5886
6626
  if (metrics.total_word_count >= min) return [];
5887
- return [block("PG-03", `text has ${metrics.total_word_count} words, minimum is ${min}`, `write at least ${min - metrics.total_word_count} more words, then call novel_word_count to verify`)];
6627
+ return [block("PG-03", `text has ${metrics.total_word_count} counted words (${metrics.cjk_chars} 汉字 + ${metrics.punctuation_chars} 标点 + ${metrics.english_words} 英文词 + ${metrics.numbers} 数字), minimum is ${min}`, `the engine counts punctuation as words; write at least ${min - metrics.total_word_count} more counted words, then call novel_word_count to verify the same metric`)];
5888
6628
  };
5889
6629
  /** PG-04: format blockers must be zero before length is judged. */
5890
6630
  const format = (input, options) => {
@@ -6289,6 +7029,17 @@ function briefStageFor(operation, stage) {
6289
7029
  }
6290
7030
  }
6291
7031
  // src/engine/projections.ts
7032
+ /** One shared prompt template for `/novel resume` and the panel copy button. */
7033
+ function formatResumePrompt(input) {
7034
+ return [
7035
+ `请继续《${input.title}》。`,
7036
+ `项目根:${input.root}(第一次工具调用必须显式传 project_root=${input.root})。`,
7037
+ `当前第 ${input.chapter} 章,阶段 ${input.stage}。`,
7038
+ `下一步:${input.nextTool}。`,
7039
+ "先调用 novel_status 核对项目根与书名,再按 novel_pipeline 的下一步执行;",
7040
+ "只操作这个项目,不要读取或写入其他书。"
7041
+ ].join("\n");
7042
+ }
6292
7043
  const STAGE_TO_PHASE = {
6293
7044
  idle: "P1",
6294
7045
  P2: "P2",
@@ -6332,8 +7083,9 @@ function projectChapter(record) {
6332
7083
  }
6333
7084
  function projectSummary(snapshot) {
6334
7085
  return {
6335
- schemaVersion: 2,
7086
+ schemaVersion: 4,
6336
7087
  panelId: PANEL_ID,
7088
+ root: snapshot.root,
6337
7089
  title: snapshot.title,
6338
7090
  genre: snapshot.genre,
6339
7091
  platform: snapshot.platform,
@@ -6375,40 +7127,280 @@ function projectChapters(snapshot) {
6375
7127
  details: draft.details
6376
7128
  });
6377
7129
  return {
6378
- schemaVersion: 2,
7130
+ schemaVersion: 4,
6379
7131
  panelId: PANEL_ID,
6380
7132
  rows
6381
7133
  };
6382
7134
  }
6383
7135
  function projectGates(snapshot) {
6384
7136
  return {
6385
- schemaVersion: 2,
7137
+ schemaVersion: 4,
6386
7138
  panelId: PANEL_ID,
6387
7139
  stage: snapshot.stage,
6388
7140
  blocking: snapshot.blocking,
6389
7141
  explanations: snapshot.blocking.map((item) => `${item.code}${item.scope === void 0 ? "" : ` (${item.scope})`}: ${item.detail}`)
6390
7142
  };
6391
7143
  }
6392
- // src/engine/state.ts
6393
- /** A pipeline document for a brand-new project. */
6394
- function emptyPipeline(patch) {
7144
+ // src/engine/project-selection.ts
7145
+ const MAX_SCAN_CANDIDATES = 50;
7146
+ const errorText = (error) => error instanceof Error ? error.message : String(error);
7147
+ /** Filesystem access failures are unreadable; parse/schema faults are damage. */
7148
+ const unreadableFsError = (error) => {
7149
+ const code = error.code;
7150
+ return typeof code === "string" && [
7151
+ "EACCES",
7152
+ "EPERM",
7153
+ "EIO",
7154
+ "EBUSY",
7155
+ "ELOOP",
7156
+ "EMFILE",
7157
+ "ENFILE",
7158
+ "EISDIR"
7159
+ ].includes(code);
7160
+ };
7161
+ /**
7162
+ * True when anything survives in the project directory besides novel.yaml.
7163
+ * Kept next to inspection so the panel, resolver and scanner agree on what
7164
+ * "a project used to be here" means.
7165
+ */
7166
+ async function hasProjectState(store) {
7167
+ const stateFiles = [
7168
+ STATE_FILES.pipeline,
7169
+ STATE_FILES.chapters,
7170
+ STATE_FILES.characters,
7171
+ STATE_FILES.world,
7172
+ STATE_FILES.locks,
7173
+ STATE_FILES.timeline,
7174
+ STATE_FILES.foreshadows,
7175
+ STATE_FILES.relationships,
7176
+ STATE_FILES.handoff
7177
+ ].map((file) => joinRel(STATE_DIR, file));
7178
+ stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS_FILE, BRIEF_RECEIPTS_FILE);
7179
+ return (await Promise.all(stateFiles.map((path) => store.exists(path)))).some(Boolean) || (await store.list("chapters")).length > 0 || (await store.list("plans")).length > 0 || (await store.list("reviews")).length > 0 || (await store.list("checks")).length > 0 || await store.exists("outline/outline.yaml") || await store.exists("outline/worldbook.yaml");
7180
+ }
7181
+ /**
7182
+ * Inspect one candidate without constructing a full Snapshot. `ok` and
7183
+ * `damaged` are both project evidence; only `not-project`/`missing` are absent.
7184
+ */
7185
+ async function inspectProject(root) {
7186
+ const absolute = resolve(root);
7187
+ const store = new FsStore(absolute);
7188
+ let directory;
7189
+ try {
7190
+ const info = await store.statRoot();
7191
+ if (info === null) return {
7192
+ root: absolute,
7193
+ state: "missing",
7194
+ title: "(项目目录不存在)"
7195
+ };
7196
+ directory = info.directory;
7197
+ } catch (error) {
7198
+ return {
7199
+ root: absolute,
7200
+ state: "unreadable",
7201
+ title: "(项目目录不可读)",
7202
+ error: errorText(error)
7203
+ };
7204
+ }
7205
+ if (!directory) return {
7206
+ root: absolute,
7207
+ state: "not-project",
7208
+ title: "(不是项目目录)"
7209
+ };
7210
+ let projectExists;
7211
+ try {
7212
+ projectExists = await store.exists(PROJECT_FILE);
7213
+ } catch (error) {
7214
+ return {
7215
+ root: absolute,
7216
+ state: "unreadable",
7217
+ title: "(项目文件不可读)",
7218
+ error: errorText(error)
7219
+ };
7220
+ }
7221
+ if (projectExists) try {
7222
+ const project = await store.readDoc(PROJECT_FILE, "project");
7223
+ if (project === null) return {
7224
+ root: absolute,
7225
+ state: "damaged",
7226
+ title: "(项目文件不可读)",
7227
+ error: `${PROJECT_FILE} disappeared during inspection`
7228
+ };
7229
+ return {
7230
+ root: absolute,
7231
+ state: "ok",
7232
+ title: typeof project.title === "string" ? project.title : "",
7233
+ ...typeof project.created_at === "string" && project.created_at !== "" ? { created_at: project.created_at } : {}
7234
+ };
7235
+ } catch (error) {
7236
+ return {
7237
+ root: absolute,
7238
+ state: unreadableFsError(error) ? "unreadable" : "damaged",
7239
+ title: "(项目文件不可读)",
7240
+ error: errorText(error)
7241
+ };
7242
+ }
7243
+ try {
7244
+ if (await hasProjectState(store)) return {
7245
+ root: absolute,
7246
+ state: "damaged",
7247
+ title: "(项目文件缺失)",
7248
+ error: `${PROJECT_FILE} is missing`
7249
+ };
7250
+ } catch (error) {
7251
+ return {
7252
+ root: absolute,
7253
+ state: "unreadable",
7254
+ title: "(项目目录不可读)",
7255
+ error: errorText(error)
7256
+ };
7257
+ }
6395
7258
  return {
6396
- schema_version: 1,
6397
- revision: patch.revision,
6398
- chapter: patch.chapter,
6399
- stage: patch.stage,
6400
- rounds: patch.rounds ?? {
6401
- review: 0,
6402
- revise: 0,
6403
- check: 0
6404
- },
6405
- briefs: patch.briefs ?? {},
6406
- blocking: patch.blocking ?? [],
6407
- last_action: patch.last_action ?? null
7259
+ root: absolute,
7260
+ state: "not-project",
7261
+ title: "(不是项目)"
6408
7262
  };
6409
7263
  }
6410
- /** Every state document a project starts with, keyed by file base name. */
6411
- function emptyDocuments() {
7264
+ /**
7265
+ * Enumerate the finite workspace shape: cwd, cwd/novel and direct child
7266
+ * directories. No ancestor walk, no recursion, and no symlink directory is
7267
+ * followed. `truncated` is explicit rather than a silent 50-row cut.
7268
+ */
7269
+ async function scanWorkspace(cwd, limit = MAX_SCAN_CANDIDATES) {
7270
+ const base = resolve(cwd);
7271
+ const store = new FsStore(base);
7272
+ const roots = [base];
7273
+ const add = (path) => {
7274
+ const absolute = resolve(path);
7275
+ if (!roots.includes(absolute)) roots.push(absolute);
7276
+ };
7277
+ try {
7278
+ const novel = await store.lstat("novel");
7279
+ if (novel !== null && novel.directory && !novel.symbolicLink) add(join(base, "novel"));
7280
+ for (const entry of await store.entries(".")) {
7281
+ if (!entry.directory || entry.symbolicLink) continue;
7282
+ add(join(base, entry.name));
7283
+ }
7284
+ } catch {}
7285
+ const bounded = Math.max(1, limit);
7286
+ const candidates = [];
7287
+ let truncated = false;
7288
+ for (const root of roots) try {
7289
+ const inspection = await inspectProject(root);
7290
+ if (inspection.state === "not-project" || inspection.state === "missing") continue;
7291
+ if (candidates.length >= bounded) {
7292
+ truncated = true;
7293
+ break;
7294
+ }
7295
+ candidates.push(inspection);
7296
+ } catch (error) {
7297
+ if (candidates.length >= bounded) {
7298
+ truncated = true;
7299
+ break;
7300
+ }
7301
+ candidates.push({
7302
+ root,
7303
+ state: "unreadable",
7304
+ title: "(项目目录不可读)",
7305
+ error: errorText(error)
7306
+ });
7307
+ }
7308
+ return {
7309
+ candidates,
7310
+ truncated
7311
+ };
7312
+ }
7313
+ /**
7314
+ * One/many/none classification with the §6.4 short-circuit order:
7315
+ * cwd itself, then cwd/novel, then exactly one readable direct child. Damaged
7316
+ * projects are not workspace discovery candidates; scan may still list them.
7317
+ */
7318
+ async function discoverWorkspace(cwd) {
7319
+ const base = resolve(cwd);
7320
+ const direct = await inspectProject(base);
7321
+ if (direct.state === "ok") return {
7322
+ kind: "one",
7323
+ root: direct.root
7324
+ };
7325
+ const store = new FsStore(base);
7326
+ const nested = join(base, "novel");
7327
+ try {
7328
+ const info = await store.lstat("novel");
7329
+ if (info !== null && info.directory && !info.symbolicLink) {
7330
+ const candidate = await inspectProject(nested);
7331
+ if (candidate.state === "ok") return {
7332
+ kind: "one",
7333
+ root: candidate.root
7334
+ };
7335
+ }
7336
+ } catch {
7337
+ return { kind: "none" };
7338
+ }
7339
+ const roots = [];
7340
+ try {
7341
+ for (const entry of await store.entries(".")) {
7342
+ if (!entry.directory || entry.symbolicLink) continue;
7343
+ const candidate = await inspectProject(join(base, entry.name)).catch(() => null);
7344
+ if (candidate?.state !== "ok") continue;
7345
+ roots.push(candidate.root);
7346
+ if (roots.length > 50) break;
7347
+ }
7348
+ } catch {
7349
+ return { kind: "none" };
7350
+ }
7351
+ if (roots.length === 0) return { kind: "none" };
7352
+ if (roots.length === 1) return {
7353
+ kind: "one",
7354
+ root: roots[0]
7355
+ };
7356
+ return {
7357
+ kind: "many",
7358
+ roots
7359
+ };
7360
+ }
7361
+ function describeCandidates(candidates) {
7362
+ return candidates.map((candidate) => {
7363
+ return `- ${candidate.title === void 0 || candidate.title === "" ? "(无标题)" : candidate.title}${candidate.state === void 0 ? "" : ` [${candidate.state}]`}: ${candidate.root}`;
7364
+ }).join("\n");
7365
+ }
7366
+ function ambiguousError(candidates) {
7367
+ const list = describeCandidates(candidates);
7368
+ return new EngineInputError(ERROR_CODES.PROJECT_AMBIGUOUS, `multiple projects are available; choose one explicitly:\n${list}`, "pass project_root on the next tool call, or use /novel use <path|title>. The registry active project is not used as a silent default for an Agent session.");
7369
+ }
7370
+ function projectNotFoundError(root) {
7371
+ return new EngineInputError(ERROR_CODES.PROJECT_NOT_FOUND, `no novel project at ${resolve(root)}`, "call novel_init to create one, pass the correct project_root, or run /novel scan to discover projects");
7372
+ }
7373
+ function missingProjectError(input) {
7374
+ const title = input.lastTitle === void 0 || input.lastTitle === "" ? "" : `《${input.lastTitle}》`;
7375
+ const moved = input.movedTo === void 0 ? "" : ` A project with the same created_at is registered at ${input.movedTo};`;
7376
+ return new EngineFaultError(ERROR_CODES.PROJECT_MISSING, `the project ${title} bound to this session is missing: ${input.selection.root}.${moved} projects are never switched automatically`, `restore the directory or mount, then retry; or explicitly run /novel use <new path> or pass project_root. The binding is kept.${input.movedTo === void 0 ? "" : ` Candidate moved project: ${input.movedTo}.`}`, input.cause === void 0 ? void 0 : { cause: input.cause });
7377
+ }
7378
+ function identityMismatchError(input) {
7379
+ const expected = input.selection.created_at ?? "(unknown)";
7380
+ const actual = input.actualCreatedAt ?? "(unknown)";
7381
+ const title = input.actualTitle === void 0 || input.actualTitle === "" ? "" : `《${input.actualTitle}》`;
7382
+ return new EngineInputError(ERROR_CODES.PROJECT_IDENTITY_MISMATCH, `the path ${input.selection.root} now contains a different project ${title} (created_at ${actual}; this session is bound to ${expected})`, "the binding was not switched and no write was attempted. Run /novel use <path> or pass project_root to confirm the replacement; old bindings keep failing closed.");
7383
+ }
7384
+ // src/engine/state.ts
7385
+ /** A pipeline document for a brand-new project. */
7386
+ function emptyPipeline(patch) {
7387
+ return {
7388
+ schema_version: 1,
7389
+ revision: patch.revision,
7390
+ chapter: patch.chapter,
7391
+ stage: patch.stage,
7392
+ rounds: patch.rounds ?? {
7393
+ review: 0,
7394
+ revise: 0,
7395
+ check: 0
7396
+ },
7397
+ briefs: patch.briefs ?? {},
7398
+ blocking: patch.blocking ?? [],
7399
+ last_action: patch.last_action ?? null
7400
+ };
7401
+ }
7402
+ /** Every state document a project starts with, keyed by file base name. */
7403
+ function emptyDocuments() {
6412
7404
  return {
6413
7405
  "chapters.yaml": {
6414
7406
  schema_version: 1,
@@ -6497,7 +7489,7 @@ const STAGE_RANK = {
6497
7489
  done: 7
6498
7490
  };
6499
7491
  /** Internal, journal-tracked archive idempotency ledger; not a user state doc. */
6500
- const ARCHIVE_DIGESTS = joinRel(STATE_DIR, "archive-digests.json");
7492
+ const ARCHIVE_DIGESTS = ARCHIVE_DIGESTS_FILE;
6501
7493
  const LEDGER_PATHS = {
6502
7494
  chapters: joinRel(STATE_DIR, STATE_FILES.chapters),
6503
7495
  characters: joinRel(STATE_DIR, STATE_FILES.characters),
@@ -6535,8 +7527,8 @@ var EngineService = class {
6535
7527
  briefContextCache = /* @__PURE__ */ new WeakMap();
6536
7528
  briefs;
6537
7529
  now;
6538
- /** The project this process last touched; the panel follows it without a workspace hint. */
6539
- lastRoot = null;
7530
+ metadataMutex = new KeyedMutex();
7531
+ callerContext = new AsyncLocalStorage();
6540
7532
  constructor(config, rulepack, options = {}) {
6541
7533
  this.config = config;
6542
7534
  this.rulepackSource = typeof rulepack === "function" ? rulepack : () => rulepack;
@@ -6558,7 +7550,6 @@ var EngineService = class {
6558
7550
  this.disposed = true;
6559
7551
  this.stores.clear();
6560
7552
  this.replay.clear();
6561
- this.lastRoot = null;
6562
7553
  this.compiled = null;
6563
7554
  }
6564
7555
  get rulepack() {
@@ -6571,23 +7562,171 @@ var EngineService = class {
6571
7562
  withSignal(signal, task) {
6572
7563
  return this.signalContext.run(signal, task);
6573
7564
  }
7565
+ /** Bind the calling Agent session and cwd to every engine operation in `task`. */
7566
+ withCaller(caller, task) {
7567
+ return this.callerContext.run(caller, task);
7568
+ }
7569
+ caller() {
7570
+ return this.callerContext.getStore();
7571
+ }
6574
7572
  /** The configured default project root. */
6575
7573
  defaultRoot() {
6576
7574
  return resolve(this.config.projectRoot);
6577
7575
  }
7576
+ /** Resolve a caller-supplied root against the session cwd, like /novel use. */
7577
+ resolveProjectPath(root) {
7578
+ const caller = this.caller();
7579
+ return resolve(caller?.cwd ?? process.cwd(), root);
7580
+ }
7581
+ /** The resolved metadata directory; resolves relative config once per call. */
7582
+ stateRoot() {
7583
+ return this.config.stateDir === "" ? resolve(homedir(), ".dsh", "novel-write-engine") : resolve(this.config.stateDir);
7584
+ }
7585
+ stateStore() {
7586
+ return new FsStore(this.stateRoot());
7587
+ }
7588
+ bindings() {
7589
+ return new SessionBindings(this.stateStore(), { now: this.now });
7590
+ }
6578
7591
  /**
6579
- * Resolve an omitted root through the cross-project registry. The registry is
6580
- * convenience state, so a corrupt or unreadable registry falls back to the
6581
- * configured root instead of making every call fail.
7592
+ * Serialize metadata read-modify-write across sessions and processes. The
7593
+ * project manuscript lock is a different lock; a slow write never blocks a
7594
+ * registry update.
6582
7595
  */
6583
- async resolveRoot(root) {
6584
- if (root !== void 0) return resolve(root);
6585
- if (this.lastRoot !== null) return this.lastRoot;
7596
+ withMetadataLock(task) {
7597
+ const directory = this.stateRoot();
7598
+ return this.metadataMutex.run(directory, async () => {
7599
+ ensureDirAt(directory);
7600
+ const lock = new FileLock(join(directory, METADATA_LOCK_FILE));
7601
+ try {
7602
+ await lock.acquire();
7603
+ } catch (error) {
7604
+ if (error instanceof EngineFaultError && error.code === ERROR_CODES.LOCK_TIMEOUT) throw new EngineFaultError(ERROR_CODES.LOCK_TIMEOUT, "timed out waiting for the project metadata lock", "another DSH instance is writing project registration or session bindings; retry after it finishes", { cause: error });
7605
+ throw error;
7606
+ }
7607
+ try {
7608
+ return await task();
7609
+ } finally {
7610
+ await lock.release();
7611
+ }
7612
+ });
7613
+ }
7614
+ async inspectConfigRoot() {
6586
7615
  try {
6587
- const document = await this.registry().read();
6588
- if (document.active !== null) return resolve(document.active);
6589
- } catch {}
6590
- return resolve(this.config.projectRoot);
7616
+ const inspection = await inspectProject(this.defaultRoot());
7617
+ return inspection.state === "ok" || inspection.state === "damaged" || inspection.state === "unreadable" ? inspection : null;
7618
+ } catch (error) {
7619
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `cannot inspect the configured projectRoot ${this.defaultRoot()}: ${errorMessage(error)}`, "fix the configured path or permissions; no project was selected", { cause: error });
7620
+ }
7621
+ }
7622
+ /**
7623
+ * Resolve the four-layer choice: explicit, session binding, bounded workspace
7624
+ * discovery, then registry/config defaults. There is deliberately no
7625
+ * process-global "last project" layer.
7626
+ */
7627
+ /** Doctor/guide may run before any project exists; that is not a failure. */
7628
+ async resolveRootForInspection(root) {
7629
+ let selection;
7630
+ try {
7631
+ selection = await this.resolveRoot(root);
7632
+ } catch (error) {
7633
+ if (root === void 0 && error instanceof EngineError && error.code === ERROR_CODES.PROJECT_NOT_FOUND) return {
7634
+ root: this.defaultRoot(),
7635
+ source: "config"
7636
+ };
7637
+ throw error;
7638
+ }
7639
+ if (root === void 0 && selection.source === "session") {
7640
+ const inspection = await inspectProject(selection.root);
7641
+ if (inspection.state === "missing") throw missingProjectError({
7642
+ selection,
7643
+ lastTitle: selection.title,
7644
+ movedTo: await this.movedProjectHint(selection)
7645
+ });
7646
+ if (inspection.state === "not-project") throw projectNotFoundError(selection.root);
7647
+ if (inspection.state === "unreadable") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `the project bound to this session is unreadable: ${inspection.error ?? selection.root}`, "fix the directory permissions, or explicitly rebind with /novel use after the project is readable");
7648
+ }
7649
+ return selection;
7650
+ }
7651
+ async resolveRoot(root) {
7652
+ if (root !== void 0) return {
7653
+ root: this.resolveProjectPath(root),
7654
+ source: "explicit"
7655
+ };
7656
+ const caller = this.caller();
7657
+ if (caller?.sessionId !== void 0) {
7658
+ const binding = await this.bindings().get(caller.sessionId);
7659
+ if (binding !== void 0) return {
7660
+ root: binding.root,
7661
+ source: "session",
7662
+ title: binding.title,
7663
+ created_at: binding.project_created_at
7664
+ };
7665
+ if (caller.cwd !== void 0 && caller.cwd !== "") {
7666
+ const found = await discoverWorkspace(caller.cwd);
7667
+ if (found.kind === "one") return {
7668
+ root: found.root,
7669
+ source: "workspace"
7670
+ };
7671
+ if (found.kind === "many") throw ambiguousError(await Promise.all(found.roots.map(async (candidate) => {
7672
+ const inspection = await inspectProject(candidate);
7673
+ return {
7674
+ root: inspection.root,
7675
+ title: inspection.title,
7676
+ state: inspection.state
7677
+ };
7678
+ })));
7679
+ }
7680
+ }
7681
+ const document = await this.registry().read();
7682
+ const candidates = document.projects.map((entry) => ({
7683
+ root: entry.root,
7684
+ title: entry.title
7685
+ }));
7686
+ const ambiguous = async () => {
7687
+ return ambiguousError(await Promise.all(document.projects.map(async (entry) => {
7688
+ const inspection = await inspectProject(entry.root).catch(() => null);
7689
+ return {
7690
+ root: entry.root,
7691
+ title: entry.title,
7692
+ ...inspection === null ? {} : { state: inspection.state }
7693
+ };
7694
+ })));
7695
+ };
7696
+ if (caller?.sessionId !== void 0) {
7697
+ if (candidates.length === 1) return {
7698
+ root: candidates[0].root,
7699
+ source: "single"
7700
+ };
7701
+ if (candidates.length > 1) throw await ambiguous();
7702
+ const config = await this.inspectConfigRoot();
7703
+ if (config !== null) {
7704
+ if (config.state === "unreadable") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `the configured projectRoot is unreadable: ${config.error ?? this.defaultRoot()}`, "fix the configured path or permissions");
7705
+ return {
7706
+ root: config.root,
7707
+ source: "config"
7708
+ };
7709
+ }
7710
+ throw projectNotFoundError(this.defaultRoot());
7711
+ }
7712
+ if (document.active !== null) return {
7713
+ root: document.active,
7714
+ source: "active"
7715
+ };
7716
+ const config = await this.inspectConfigRoot();
7717
+ if (config !== null) {
7718
+ if (config.state === "unreadable") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `the configured projectRoot is unreadable: ${config.error ?? this.defaultRoot()}`, "fix the configured path or permissions");
7719
+ return {
7720
+ root: config.root,
7721
+ source: "config"
7722
+ };
7723
+ }
7724
+ if (candidates.length === 1) return {
7725
+ root: candidates[0].root,
7726
+ source: "single"
7727
+ };
7728
+ if (candidates.length > 1) throw await ambiguous();
7729
+ throw projectNotFoundError(this.defaultRoot());
6591
7730
  }
6592
7731
  storeFor(root) {
6593
7732
  const absolute = resolve(root ?? this.config.projectRoot);
@@ -6597,6 +7736,69 @@ var EngineService = class {
6597
7736
  this.stores.set(absolute, store);
6598
7737
  return store;
6599
7738
  }
7739
+ /** Persist one explicit choice for this DSH session. */
7740
+ async bindSelection(root, title, projectCreatedAt, source) {
7741
+ const caller = this.caller();
7742
+ if (caller?.sessionId === void 0) return;
7743
+ const existing = await this.bindings().get(caller.sessionId);
7744
+ if (existing !== void 0 && existing.root === resolve(root) && existing.title === title && existing.project_created_at === projectCreatedAt) return;
7745
+ await this.withMetadataLock(() => this.bindings().bind({
7746
+ session_id: caller.sessionId,
7747
+ root,
7748
+ title,
7749
+ project_created_at: projectCreatedAt,
7750
+ source
7751
+ }));
7752
+ }
7753
+ /**
7754
+ * Best-effort discovery bookkeeping. A metadata failure must never turn a
7755
+ * successful project read/write into a failure; the registry is convenience.
7756
+ */
7757
+ async autoRegister(snapshot) {
7758
+ if (snapshot.projectError !== null) return;
7759
+ try {
7760
+ const known = (await this.registry().read()).projects.find((entry) => entry.root === snapshot.root);
7761
+ const sameRow = known !== void 0 && known.title === snapshot.project.title && (known.created_at ?? "") === (snapshot.project.created_at ?? "");
7762
+ const differentIdentity = known !== void 0 && known.created_at !== void 0 && known.created_at !== "" && known.created_at !== snapshot.project.created_at;
7763
+ if (sameRow || differentIdentity) return;
7764
+ await this.withMetadataLock(async () => {
7765
+ const lockedRegistry = this.registry();
7766
+ const existing = (await lockedRegistry.read()).projects.find((entry) => entry.root === snapshot.root);
7767
+ if (existing !== void 0) {
7768
+ if (existing.created_at !== void 0 && existing.created_at !== "" && existing.created_at !== snapshot.project.created_at) return;
7769
+ if (existing.title === snapshot.project.title && (existing.created_at ?? "") === (snapshot.project.created_at ?? "")) return;
7770
+ }
7771
+ await lockedRegistry.register({
7772
+ root: snapshot.root,
7773
+ title: snapshot.project.title,
7774
+ created_at: snapshot.project.created_at
7775
+ });
7776
+ });
7777
+ } catch {}
7778
+ }
7779
+ /** Record the session binding/registration implied by a successful selection. */
7780
+ async recordSelection(selection, snapshot, options = {}) {
7781
+ if (this.caller()?.sessionId === void 0) return;
7782
+ if ((options.bind ?? (options.bindSource !== void 0 || selection.source === "explicit")) && snapshot.projectError === null) await this.bindSelection(snapshot.root, snapshot.project.title, snapshot.project.created_at, options.bindSource ?? "explicit");
7783
+ if (options.register ?? (selection.source === "explicit" || selection.source === "workspace")) await this.autoRegister(snapshot);
7784
+ }
7785
+ /**
7786
+ * Read paths may record convenience metadata, but a corrupt or read-only
7787
+ * state directory must not turn a successful explicit-root read into a
7788
+ * failure. Resolution without an explicit root still reads the binding
7789
+ * fail-closed; writes use recordSelection directly and report a committed
7790
+ * mutation with a warning if its metadata update fails.
7791
+ */
7792
+ async recordSelectionBestEffort(selection, snapshot) {
7793
+ try {
7794
+ await this.recordSelection(selection, snapshot);
7795
+ } catch {}
7796
+ }
7797
+ /** Registration/activation/use need a readable project identity. */
7798
+ assertProjectHealthy(snapshot, operation) {
7799
+ if (snapshot.projectError === null) return;
7800
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${operation} requires a readable novel.yaml: ${snapshot.projectError}`, "repair the project definition with novel_status + novel_amend using an explicit project_root; the registry/binding was not changed");
7801
+ }
6600
7802
  async initProject(input) {
6601
7803
  const title = typeof input.title === "string" ? input.title.trim() : "";
6602
7804
  if (title === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "title must not be empty");
@@ -6615,10 +7817,16 @@ var EngineService = class {
6615
7817
  const [min, max] = input.dialogue_ratio;
6616
7818
  if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max > 1 || max < min) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "dialogue_ratio must be [min, max] within 0..1 with min <= max");
6617
7819
  }
6618
- return this.mutate({
6619
- root: input.root,
7820
+ const selection = {
7821
+ root: input.root === void 0 ? this.defaultRoot() : this.resolveProjectPath(input.root),
7822
+ source: input.root === void 0 ? "config" : "explicit"
7823
+ };
7824
+ const result = await this.mutate({
7825
+ selection,
6620
7826
  op: "initProject",
6621
- key: () => `init:${resolve(input.root ?? this.config.projectRoot)}`,
7827
+ bindOnSuccess: false,
7828
+ registerOnSuccess: false,
7829
+ key: () => `init:${selection.root}`,
6622
7830
  run: async (_snapshot, context) => {
6623
7831
  if (await context.store.exists("novel.yaml")) throw new EngineInputError(ERROR_CODES.PROJECT_EXISTS, `a project already exists at ${context.store.root}`, "use a different directory, or call novel_status to inspect the existing project");
6624
7832
  const project = {
@@ -6675,6 +7883,13 @@ var EngineService = class {
6675
7883
  path: PROJECT_FILE,
6676
7884
  text: yaml(project)
6677
7885
  },
7886
+ {
7887
+ path: BRIEF_RECEIPTS_FILE,
7888
+ text: yaml({
7889
+ schema_version: 1,
7890
+ receipts: []
7891
+ })
7892
+ },
6678
7893
  {
6679
7894
  path: joinRel(STATE_DIR, STATE_FILES.pipeline),
6680
7895
  text: yaml(pipeline)
@@ -6698,11 +7913,32 @@ var EngineService = class {
6698
7913
  title: project.title,
6699
7914
  chapter: 1,
6700
7915
  stage: "idle"
6701
- }
7916
+ },
7917
+ warnings: [...project.author.trim() === "" ? [{
7918
+ code: "PROJECT_AUTHOR_EMPTY",
7919
+ message: "作者为空;proposal 与导出会缺作者,可用 novel_amend 补 novel.yaml.author"
7920
+ }] : [], ...project.description.trim() === "" ? [{
7921
+ code: "PROJECT_DESCRIPTION_EMPTY",
7922
+ message: "简介为空;proposal 与导出会缺简介,可用 novel_amend 补 novel.yaml.description"
7923
+ }] : []]
6702
7924
  };
6703
7925
  },
6704
7926
  emptyAllowed: true
6705
7927
  });
7928
+ const created = await this.loadAt({
7929
+ root: result.data.root,
7930
+ source: "explicit"
7931
+ });
7932
+ await this.autoRegister(created);
7933
+ try {
7934
+ await this.bindSelection(created.root, created.project.title, created.project.created_at, "init");
7935
+ } catch (error) {
7936
+ result.warnings.push({
7937
+ code: "SESSION_BINDING_FAILED",
7938
+ message: `project was created, but the session binding could not be written: ${errorMessage(error)}`
7939
+ });
7940
+ }
7941
+ return result;
6706
7942
  }
6707
7943
  async submitBookPlan(input) {
6708
7944
  const allowed = /* @__PURE__ */ new Set([
@@ -7023,6 +8259,7 @@ var EngineService = class {
7023
8259
  "characters",
7024
8260
  "glossary",
7025
8261
  "backstory",
8262
+ "facts",
7026
8263
  "themes"
7027
8264
  ]);
7028
8265
  const keys = Object.entries(input).filter(([key, value]) => key !== "root" && value !== void 0).map(([key]) => key);
@@ -7050,9 +8287,10 @@ var EngineService = class {
7050
8287
  locations: worldbook.locations.length,
7051
8288
  characters: worldbook.characters.length,
7052
8289
  glossary: worldbook.glossary.length,
7053
- backstory: worldbook.backstory.length
8290
+ backstory: worldbook.backstory.length,
8291
+ facts: (worldbook.facts ?? []).length
7054
8292
  };
7055
- const entries = counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory;
8293
+ const entries = counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory + counts.facts;
7056
8294
  const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_worldbook", this.now(), shortDigest(stableJson(input))) });
7057
8295
  return {
7058
8296
  files: [{
@@ -7092,6 +8330,7 @@ var EngineService = class {
7092
8330
  return this.mutate({
7093
8331
  root: input.root,
7094
8332
  op: "reopenChapter",
8333
+ chapter: input.chapter,
7095
8334
  reason,
7096
8335
  key: () => `reopen:${input.chapter}:${shortDigest(reason)}`,
7097
8336
  run: async (snapshot, context) => {
@@ -7116,7 +8355,7 @@ var EngineService = class {
7116
8355
  if (changedAfterArchive.length > 0) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `refusing to reopen: ${changedAfterArchive.join(", ")} changed after chapter ${input.chapter} was archived`, "move the amended files aside or restore the archived versions first; reopening would overwrite them silently");
7117
8356
  const files = [];
7118
8357
  for (const path of archive.files) {
7119
- if (path === joinRel("state", STATE_FILES.pipeline) || path === "outline/outline.yaml") continue;
8358
+ if (path === joinRel("state", STATE_FILES.pipeline) || path === "outline/outline.yaml" || path === planChapterFile(input.chapter)) continue;
7120
8359
  const hasBeforeEntry = beforeHashes !== void 0 && Object.hasOwn(beforeHashes, path);
7121
8360
  const expectedBefore = hasBeforeEntry ? beforeHashes[path] : void 0;
7122
8361
  if (hasBeforeEntry && expectedBefore === null) {
@@ -7180,6 +8419,15 @@ var EngineService = class {
7180
8419
  briefs: {},
7181
8420
  blocking: [],
7182
8421
  review_digest: void 0,
8422
+ draft: {
8423
+ chapter: input.chapter,
8424
+ title: stripChapterPrefix(plan.title),
8425
+ scene_type: plan.scene_type,
8426
+ narrator: plan.narrator,
8427
+ summary: record.summary,
8428
+ content_hash: record.content_hash,
8429
+ updated_at: reopenedAt
8430
+ },
7183
8431
  last_action: action("novel_reopen", this.now(), shortDigest(reason))
7184
8432
  });
7185
8433
  delete nextPipeline.review_digest;
@@ -7213,6 +8461,126 @@ var EngineService = class {
7213
8461
  });
7214
8462
  }
7215
8463
  /**
8464
+ * Restore one active chapter from a backup transaction. Archived chapters
8465
+ * are refused: their manuscript hash is bound by the index, so restoring
8466
+ * behind the index would corrupt the project. Reopen first, then restore.
8467
+ */
8468
+ async restoreBackup(input) {
8469
+ if (typeof input.txid !== "string" || !/^[0-9a-f-]{36}$/i.test(input.txid)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restore txid must be a transaction UUID");
8470
+ if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
8471
+ if (input.paths !== void 0 && (!Array.isArray(input.paths) || input.paths.length === 0)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restore paths must be a non-empty array");
8472
+ return this.mutate({
8473
+ root: input.root,
8474
+ op: "restoreBackup",
8475
+ chapter: input.chapter,
8476
+ reason: `restore backup ${input.txid}`,
8477
+ run: async (snapshot, context) => {
8478
+ const manifest = await readBackupManifest(context.store, input.txid);
8479
+ 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");
8480
+ const chapter = input.chapter ?? snapshot.pipeline.chapter;
8481
+ 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");
8482
+ 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");
8483
+ if (manifest.chapter !== chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} belongs to chapter ${manifest.chapter}, not chapter ${chapter}`, "choose a backup whose manifest chapter matches the active chapter");
8484
+ const fileFor = (path) => `backups/${input.txid}/${path}`;
8485
+ const allowed = [
8486
+ chapterFile(chapter),
8487
+ planChapterFile(chapter),
8488
+ planWorldFile(chapter),
8489
+ reviewFile(chapter),
8490
+ checkFile(chapter)
8491
+ ];
8492
+ const requested = input.paths ?? allowed.filter((path) => manifest.files.includes(path));
8493
+ for (const path of requested) if (!allowed.includes(path)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `restore path ${path} is not a chapter file for chapter ${chapter}`, `allowed: ${allowed.join(", ")}`);
8494
+ if (requested.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains no restorable file for chapter ${chapter}`, "choose a backup whose files include the chapter you are restoring");
8495
+ const files = [];
8496
+ let restoredReview = null;
8497
+ let restoredCheck = null;
8498
+ for (const path of requested) {
8499
+ if (!manifest.files.includes(path)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} manifest does not record ${path}`, "restore only files that the transaction captured as pre-images");
8500
+ const text = await context.store.readText(fileFor(path));
8501
+ if (text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${path}`, "the file was created by that transaction, so it has no pre-image to restore");
8502
+ const kind = path === planChapterFile(chapter) ? "chapterPlan" : path === planWorldFile(chapter) ? "worldPlan" : path === reviewFile(chapter) ? "review" : path === checkFile(chapter) ? "check" : null;
8503
+ if (kind !== null) try {
8504
+ const parsed = parse(text);
8505
+ const row = recordOf(parsed);
8506
+ if (row === null) throw new Error("document is not a mapping");
8507
+ if (kind === "chapterPlan") {
8508
+ validateStoredChapterPlan(row, path);
8509
+ if (row.chapter !== chapter) throw new Error(`document belongs to chapter ${String(row.chapter)}, not ${chapter}`);
8510
+ } else if (kind === "worldPlan") {
8511
+ validateWorldPlanShape(narrow("worldPlan", parsed), path);
8512
+ if (row.chapter !== chapter) throw new Error(`document belongs to chapter ${String(row.chapter)}, not ${chapter}`);
8513
+ } else {
8514
+ const restored = narrow(kind, parsed);
8515
+ if (restored.chapter !== chapter) throw new Error(`document belongs to chapter ${String(restored.chapter)}, not ${chapter}`);
8516
+ if (kind === "review") restoredReview = restored;
8517
+ if (kind === "check") restoredCheck = restored;
8518
+ }
8519
+ } catch (error) {
8520
+ throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid ${kind} document: ${errorMessage(error)}`, "restore a different backup or repair the file manually");
8521
+ }
8522
+ files.push({
8523
+ path,
8524
+ text
8525
+ });
8526
+ }
8527
+ const clearDraft = requested.includes(chapterFile(chapter)) || requested.includes(planChapterFile(chapter));
8528
+ const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_restore", this.now(), shortDigest(input.txid)) });
8529
+ if (clearDraft) delete nextPipeline.draft;
8530
+ if (restoredReview !== null) nextPipeline.review_digest = sha256(stableJson(restoredReview));
8531
+ if (restoredReview !== null || restoredCheck !== null) {
8532
+ const effectiveReview = restoredReview ?? snapshot.review;
8533
+ const effectiveCheck = restoredCheck ?? snapshot.check;
8534
+ const reviewBlocking = effectiveReview === null ? [] : [...effectiveReview.issues.filter((issue) => issue.severity === "blocker" && issue.status !== "fixed").map((issue) => ({
8535
+ code: `ISSUE-${issue.id}`,
8536
+ scope: issue.dimension,
8537
+ detail: `${issue.problem} → ${issue.fix}`,
8538
+ hint: issue.location
8539
+ })), ...effectiveReview.total < this.config.quality.minScore ? [{
8540
+ code: "SCORE-BELOW-MIN",
8541
+ scope: "quality",
8542
+ detail: `score ${effectiveReview.total} is below the minimum ${this.config.quality.minScore}`,
8543
+ hint: "revise the chapter and submit a new P4 review"
8544
+ }] : []];
8545
+ const checkMatches = effectiveCheck !== null && effectiveCheck.content_hash === sha256(snapshot.text ?? "");
8546
+ const checkBlocking = effectiveCheck === null || !checkMatches || effectiveCheck.verdict === "pass" ? [] : [...effectiveCheck.machine.violations.map((violation) => ({
8547
+ code: violation.code,
8548
+ scope: "check",
8549
+ detail: violation.detail
8550
+ })), ...evaluateSemantic(snapshot.text ?? "", effectiveCheck.semantic.items).blocking];
8551
+ nextPipeline.blocking = [...reviewBlocking, ...checkBlocking];
8552
+ }
8553
+ files.push({
8554
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8555
+ text: yaml(nextPipeline)
8556
+ });
8557
+ return {
8558
+ files,
8559
+ backup: true,
8560
+ data: {
8561
+ txid: input.txid,
8562
+ chapter,
8563
+ restored: requested
8564
+ },
8565
+ warnings: clearDraft ? [{
8566
+ code: "RESTORE_DRAFT_CLEARED",
8567
+ message: "the restored manuscript/plan invalidated the live draft summary; call novel_rollback to P3, then submit novel_write_draft again"
8568
+ }] : [],
8569
+ event: {
8570
+ name: "novel/restored",
8571
+ payload: {
8572
+ schemaVersion: 1,
8573
+ projectRoot: snapshot.root,
8574
+ txid: input.txid,
8575
+ chapter,
8576
+ paths: requested
8577
+ }
8578
+ }
8579
+ };
8580
+ }
8581
+ });
8582
+ }
8583
+ /**
7216
8584
  * Read-only paths must surface a committed-journal mismatch. The write path
7217
8585
  * already fails closed; without this, status recommends the next write and
7218
8586
  * the model only learns about the mismatch when that write aborts.
@@ -7256,10 +8624,32 @@ var EngineService = class {
7256
8624
  }];
7257
8625
  }
7258
8626
  }
8627
+ /**
8628
+ * Count every committed attempt for one chapter, not just the loop since the
8629
+ * last rollback. `pipeline.rounds` is deliberately reset by rollback to keep
8630
+ * the retry budget fair; it is not the chapter's lifetime effort.
8631
+ */
8632
+ async lifetimeRounds(snapshot, chapter) {
8633
+ const { rows } = await readJournal(this.storeFor(snapshot.root));
8634
+ const committed = (op) => rows.filter((row) => row.status === "committed" && row.chapter === chapter && row.op === op).length;
8635
+ return {
8636
+ review: Math.max(snapshot.pipeline.rounds.review, committed("submitReview")),
8637
+ revise: Math.max(snapshot.pipeline.rounds.revise, committed("submitRevision")),
8638
+ check: Math.max(snapshot.pipeline.rounds.check, committed("runCheck")),
8639
+ rollbacks: committed("rollback")
8640
+ };
8641
+ }
7259
8642
  async getStatus(input = {}) {
7260
8643
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
7261
8644
  const snapshot = await this.load(input.root);
7262
- const chapter = input.chapter ?? snapshot.pipeline.chapter;
8645
+ return this.statusFor(snapshot, input.chapter ?? snapshot.pipeline.chapter);
8646
+ }
8647
+ /**
8648
+ * Build a status report from an already-loaded snapshot. This deliberately
8649
+ * has no selection side effect: read-only callers such as /novel resume must
8650
+ * not register or bind the project just by reading it.
8651
+ */
8652
+ async statusFor(snapshot, chapter) {
7263
8653
  const metrics = await this.metricsFor(snapshot, chapter);
7264
8654
  const journal = await this.journalBlocking(snapshot);
7265
8655
  const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
@@ -7281,6 +8671,10 @@ var EngineService = class {
7281
8671
  rounds: { ...snapshot.pipeline.rounds },
7282
8672
  blocking: [...this.effectiveBlocking(snapshot), ...journal],
7283
8673
  state_errors: this.stateErrors(snapshot),
8674
+ selection: {
8675
+ source: snapshot.selection.source,
8676
+ root: snapshot.selection.root
8677
+ },
7284
8678
  metrics,
7285
8679
  ...snapshot.handoff === null ? {} : (() => {
7286
8680
  const position = projectHandoff(snapshot.handoff, snapshot.pipeline.chapter);
@@ -7316,6 +8710,7 @@ var EngineService = class {
7316
8710
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
7317
8711
  const snapshot = await this.load(input.root);
7318
8712
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
8713
+ if (input.revision !== void 0 && input.kind !== "history") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision is only meaningful for kind:\"history\"", "request history with chapter+revision, or omit revision for current documents");
7319
8714
  const store = this.storeFor(snapshot.root);
7320
8715
  const ledgerPath = LEDGER_PATHS[input.kind];
7321
8716
  if (ledgerPath !== void 0) {
@@ -7333,7 +8728,10 @@ var EngineService = class {
7333
8728
  }
7334
8729
  switch (input.kind) {
7335
8730
  case "pipeline": return {
7336
- data: snapshot.pipeline,
8731
+ data: {
8732
+ ...snapshot.pipeline,
8733
+ briefs: snapshot.briefs
8734
+ },
7337
8735
  revision: snapshot.pipeline.revision,
7338
8736
  warnings: []
7339
8737
  };
@@ -7428,6 +8826,66 @@ var EngineService = class {
7428
8826
  revision: snapshot.pipeline.revision,
7429
8827
  warnings: []
7430
8828
  };
8829
+ case "briefs": {
8830
+ const receipts = await this.readBriefReceipts(store);
8831
+ if (receipts.receipts.length > 0) return {
8832
+ data: receipts,
8833
+ revision: snapshot.pipeline.revision,
8834
+ warnings: []
8835
+ };
8836
+ return {
8837
+ data: {
8838
+ schema_version: 1,
8839
+ receipts: Object.entries(snapshot.briefs).map(([stage, receipt]) => ({
8840
+ chapter: snapshot.pipeline.chapter,
8841
+ stage,
8842
+ digest: receipt.digest,
8843
+ issued_at: receipt.issued_at
8844
+ }))
8845
+ },
8846
+ revision: snapshot.pipeline.revision,
8847
+ warnings: []
8848
+ };
8849
+ }
8850
+ case "history": {
8851
+ const entries = await listHistory(store, input.chapter);
8852
+ if (input.revision !== void 0) {
8853
+ if (!Number.isInteger(input.revision) || input.revision < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "history revision must be a positive integer");
8854
+ const matching = entries.filter((entry) => entry.chapter === chapter && entry.revision === input.revision);
8855
+ const contents = [];
8856
+ for (const entry of matching) contents.push({
8857
+ ...entry,
8858
+ text: await store.readText(entry.path)
8859
+ });
8860
+ return {
8861
+ data: {
8862
+ schema_version: 1,
8863
+ chapter,
8864
+ revision: input.revision,
8865
+ files: contents
8866
+ },
8867
+ revision: snapshot.pipeline.revision,
8868
+ warnings: []
8869
+ };
8870
+ }
8871
+ return {
8872
+ data: {
8873
+ schema_version: 1,
8874
+ entries,
8875
+ total: entries.length
8876
+ },
8877
+ revision: snapshot.pipeline.revision,
8878
+ warnings: []
8879
+ };
8880
+ }
8881
+ case "backups": return {
8882
+ data: {
8883
+ schema_version: 1,
8884
+ backups: await listBackups(store)
8885
+ },
8886
+ revision: snapshot.pipeline.revision,
8887
+ warnings: []
8888
+ };
7431
8889
  case "review":
7432
8890
  if (chapter === snapshot.pipeline.chapter && snapshot.reviewError !== null) return {
7433
8891
  data: {
@@ -7609,13 +9067,14 @@ var EngineService = class {
7609
9067
  };
7610
9068
  }
7611
9069
  async doctor(input = {}) {
7612
- const root = await this.resolveRoot(input.root);
9070
+ const selection = await this.resolveRootForInspection(input.root);
9071
+ const root = selection.root;
7613
9072
  const store = this.storeFor(root);
7614
9073
  const checks = [];
7615
9074
  const add = (check) => {
7616
9075
  checks.push(check);
7617
9076
  };
7618
- if (!await store.exists("novel.yaml") && !await this.projectStateExists(store)) {
9077
+ if (!await store.exists("novel.yaml") && !await hasProjectState(store)) {
7619
9078
  add({
7620
9079
  id: "project",
7621
9080
  severity: "error",
@@ -7640,7 +9099,8 @@ var EngineService = class {
7640
9099
  }
7641
9100
  let snapshot;
7642
9101
  try {
7643
- snapshot = await this.load(root);
9102
+ snapshot = await this.loadAt(selection);
9103
+ await this.recordSelectionBestEffort(selection, snapshot);
7644
9104
  } catch (error) {
7645
9105
  add({
7646
9106
  id: "load",
@@ -7774,13 +9234,14 @@ var EngineService = class {
7774
9234
  const archivedCards = new Set(snapshot.outline.chapters.filter((card) => card.status === "archived").map((card) => card.num));
7775
9235
  const missingCards = archivedRecords.filter((record) => !archivedCards.has(record.num));
7776
9236
  const extraCards = [...archivedCards].filter((num) => !archivedRecords.some((record) => record.num === num));
7777
- const coverage = archivedRecords.length === 0 ? 0 : (archivedRecords.length - missingCards.length) / archivedRecords.length;
9237
+ const outlineProgress = outlineSummary(snapshot.outline, archivedRecords.length, snapshot.project.targets.total_chapters, archivedRecords.map((record) => record.num));
9238
+ const percentText = (value) => value === null ? "—" : `${Math.round(value * 1e3) / 10}%`;
7778
9239
  add({
7779
9240
  id: "outline-coverage",
7780
- severity: missingCards.length === 0 && extraCards.length === 0 ? "ok" : "warning",
9241
+ severity: missingCards.length === 0 && extraCards.length === 0 && (outlineProgress.target === null || outlineProgress.missingCards === 0) ? "ok" : "warning",
7781
9242
  title: "大纲覆盖率",
7782
- detail: archivedRecords.length === 0 ? "0%(暂无已归档章节)" : `${Math.round(coverage * 100)}% · ${missingCards.length} 章缺卡 · ${extraCards.length} 张卡无正文`,
7783
- ...missingCards.length === 0 && extraCards.length === 0 ? {} : { hint: `missing cards: ${missingCards.map((record) => record.num).join(", ") || "none"}; extra cards: ${extraCards.join(", ") || "none"}` }
9243
+ detail: `规划覆盖 ${percentText(outlineProgress.plannedCoverage)}(${outlineProgress.total}/${outlineProgress.target ?? "不限"} 张卡)· 归档覆盖 ${percentText(outlineProgress.archivedCoverage)}(${archivedRecords.length}/${outlineProgress.target ?? "不限"} 章)· ${outlineProgress.missingCards} 章缺卡 · ${missingCards.length} 章有正文无卡 · ${extraCards.length} 张卡无正文`,
9244
+ ...missingCards.length === 0 && extraCards.length === 0 && (outlineProgress.target === null || outlineProgress.missingCards === 0) ? {} : { hint: `缺卡章节数 ${outlineProgress.missingCards}; archived chapters without a card: ${missingCards.map((record) => record.num).join(", ") || "none"}; cards without a manuscript: ${extraCards.join(", ") || "none"}` }
7784
9245
  });
7785
9246
  const stageMissing = [];
7786
9247
  switch (snapshot.pipeline.stage) {
@@ -7815,11 +9276,20 @@ var EngineService = class {
7815
9276
  detail: stageMissing.length === 0 ? `${snapshot.pipeline.stage} 阶段所需产物齐全` : `缺少:${stageMissing.join("、")}`,
7816
9277
  ...stageMissing.length === 0 ? {} : { hint: "use novel_brief/novel_status to rebuild the missing stage product" }
7817
9278
  });
9279
+ const restoreCandidates = /* @__PURE__ */ new Set([
9280
+ chapterFile(snapshot.pipeline.chapter),
9281
+ planChapterFile(snapshot.pipeline.chapter),
9282
+ planWorldFile(snapshot.pipeline.chapter),
9283
+ reviewFile(snapshot.pipeline.chapter),
9284
+ checkFile(snapshot.pipeline.chapter)
9285
+ ]);
9286
+ const activeRestorable = backups.find((entry) => entry.op !== null && entry.chapter === snapshot.pipeline.chapter && entry.files.some((path) => restoreCandidates.has(path)));
7818
9287
  add({
7819
9288
  id: "backups",
7820
9289
  severity: "info",
7821
9290
  title: "备份",
7822
- detail: `${backups.length} backup snapshot(s)`
9291
+ detail: backups.length === 0 ? "0 个备份快照" : `${backups.length} 个备份快照;最新:${backups[0].op ?? "旧版备份"}${backups[0].chapter === null ? "" : ` · 第 ${backups[0].chapter} 章`} · ${backups[0].files.length} 个文件`,
9292
+ ...backups.length === 0 ? {} : activeRestorable !== void 0 ? { hint: `用 novel_state {kind:"backups"} 列出;用 novel_restore {txid:"${activeRestorable.name}"} 恢复活动章` } : backups[0].op === null || backups[0].files.length === 0 ? { hint: "最新备份没有 manifest 或没有可恢复的 pre-image,只能列出;它不是 novel_restore 的候选" } : { hint: "最新备份不属于活动章或没有本章可恢复的 pre-image;用 novel_state {kind:\"backups\"} 列出,归档章先 novel_reopen" }
7823
9293
  });
7824
9294
  const locked = await store.exists(LOCK_FILE);
7825
9295
  add({
@@ -7875,13 +9345,25 @@ var EngineService = class {
7875
9345
  const store = this.storeFor(snapshot.root);
7876
9346
  const reviews = [];
7877
9347
  const reviewIssues = [];
9348
+ const historicalEntries = await listHistory(store);
7878
9349
  for (const record of snapshot.chapters.chapters) try {
7879
9350
  const review = await store.readDoc(reviewFile(record.num), "review");
7880
9351
  if (review === null) reviewIssues.push(`chapter ${record.num}: review is missing`);
7881
9352
  else reviews.push(review);
9353
+ for (const historical of await historyTexts(store, "reviews", record.num, historicalEntries)) try {
9354
+ const parsed = narrow("review", parse(historical.text));
9355
+ reviews.push(parsed);
9356
+ } catch (error) {
9357
+ reviewIssues.push(`chapter ${record.num} ${historical.path}: ${errorMessage(error)}`);
9358
+ }
7882
9359
  } catch (error) {
7883
9360
  reviewIssues.push(`chapter ${record.num}: ${errorMessage(error)}`);
7884
9361
  }
9362
+ const uniqueReviews = /* @__PURE__ */ new Map();
9363
+ for (const review of reviews) {
9364
+ const key = stableJson(review);
9365
+ if (!uniqueReviews.has(key)) uniqueReviews.set(key, review);
9366
+ }
7885
9367
  const journal = await readJournal(store);
7886
9368
  const report = buildBookReport({
7887
9369
  project: snapshot.project,
@@ -7896,7 +9378,7 @@ var EngineService = class {
7896
9378
  timeline: snapshot.timeline,
7897
9379
  foreshadows: snapshot.foreshadows,
7898
9380
  relationships: snapshot.relationships,
7899
- reviews,
9381
+ reviews: [...uniqueReviews.values()],
7900
9382
  activity: journal.rows.slice(-8),
7901
9383
  targetChapters: snapshot.project.targets.total_chapters,
7902
9384
  minScore: this.config.quality.minScore,
@@ -7913,9 +9395,10 @@ var EngineService = class {
7913
9395
  };
7914
9396
  }
7915
9397
  async guide(input = {}) {
7916
- const root = await this.resolveRoot(input.root);
9398
+ const selection = await this.resolveRootForInspection(input.root);
9399
+ const root = selection.root;
7917
9400
  const store = this.storeFor(root);
7918
- if (!await store.exists("novel.yaml") && !await this.projectStateExists(store)) return {
9401
+ if (!await store.exists("novel.yaml") && !await hasProjectState(store)) return {
7919
9402
  data: buildGuide({
7920
9403
  projectPresent: false,
7921
9404
  chapter: 1,
@@ -7971,7 +9454,8 @@ var EngineService = class {
7971
9454
  revision: 1,
7972
9455
  warnings: []
7973
9456
  };
7974
- const snapshot = await this.load(root);
9457
+ const snapshot = await this.loadAt(selection);
9458
+ await this.recordSelectionBestEffort(selection, snapshot);
7975
9459
  const target = snapshot.project.targets.total_chapters;
7976
9460
  const finished = snapshot.pipeline.stage === "done" && target !== null && snapshot.chapters.chapters.length >= target;
7977
9461
  const journalBlocking = await this.journalBlocking(snapshot);
@@ -8007,6 +9491,8 @@ var EngineService = class {
8007
9491
  return this.mutate({
8008
9492
  root: input.root,
8009
9493
  op: "getBrief",
9494
+ chapter: input.chapter,
9495
+ advanceRevision: false,
8010
9496
  run: async (snapshot, context) => {
8011
9497
  const chapter = input.chapter ?? snapshot.pipeline.chapter;
8012
9498
  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}`);
@@ -8019,6 +9505,20 @@ var EngineService = class {
8019
9505
  chapter,
8020
9506
  digest,
8021
9507
  min_words: snapshot.project.targets.words_per_chapter[0],
9508
+ max_words: snapshot.project.targets.words_per_chapter[1],
9509
+ word_count_policy: {
9510
+ metric: "total_word_count",
9511
+ includes_punctuation: true,
9512
+ formula: "cjk_chars + english_words + numbers + punctuation_chars",
9513
+ note: "引擎与商业平台一致把标点计入字数;brief/status/check 的 min_words 都是这个口径"
9514
+ },
9515
+ dialogue_ratio_target: snapshot.project.targets.dialogue_ratio,
9516
+ dialogue_ratio_metric: "含引号行数 / 非空行数",
9517
+ delta_rules: {
9518
+ title_is_a_label: true,
9519
+ anchor_required_for_verification: true,
9520
+ rule: "伏笔 title 可以写成可读短语;要逐字校验请在 anchor 里放正文原句(至少 8 个字符)。缺 anchor 时 title 必须逐字出现在正文。"
9521
+ },
8022
9522
  min_score: this.config.quality.minScore,
8023
9523
  scene_types: [
8024
9524
  "战斗/动作章",
@@ -8051,26 +9551,28 @@ var EngineService = class {
8051
9551
  fields,
8052
9552
  context: contextPack
8053
9553
  };
8054
- const existing = snapshot.pipeline.briefs[input.stage];
9554
+ const existing = snapshot.briefs[input.stage];
8055
9555
  if (existing !== void 0 && existing.digest === digest) return {
8056
9556
  files: [],
8057
9557
  data,
8058
9558
  replay: true
8059
9559
  };
8060
- const nextPipeline = pipelineWith(snapshot, context.revision, {
8061
- briefs: {
8062
- ...snapshot.pipeline.briefs,
8063
- [input.stage]: {
8064
- issued_at: this.now().toISOString(),
8065
- digest
8066
- }
8067
- },
8068
- last_action: action("novel_brief", this.now(), digest)
9560
+ const issuedAt = this.now().toISOString();
9561
+ const receiptDocument = await this.readBriefReceipts(context.store);
9562
+ receiptDocument.receipts.push({
9563
+ chapter,
9564
+ stage: input.stage,
9565
+ digest,
9566
+ issued_at: issuedAt
8069
9567
  });
9568
+ const receipts = receiptDocument.receipts;
8070
9569
  return {
8071
9570
  files: [{
8072
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8073
- text: yaml(nextPipeline)
9571
+ path: BRIEF_RECEIPTS_FILE,
9572
+ text: yaml({
9573
+ schema_version: 1,
9574
+ receipts
9575
+ })
8074
9576
  }],
8075
9577
  backup: false,
8076
9578
  data
@@ -8139,6 +9641,7 @@ var EngineService = class {
8139
9641
  path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8140
9642
  text: yaml(nextPipeline)
8141
9643
  }],
9644
+ backup: snapshot.worldPlan !== null,
8142
9645
  data: {
8143
9646
  chapter,
8144
9647
  stage: "P2",
@@ -8193,13 +9696,21 @@ var EngineService = class {
8193
9696
  last_action: action("novel_plan_chapter", this.now(), shortDigest(stableJson(plan)))
8194
9697
  });
8195
9698
  return {
8196
- files: [{
8197
- path,
8198
- text: yaml(plan)
8199
- }, {
8200
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8201
- text: yaml(nextPipeline)
8202
- }],
9699
+ files: [
9700
+ ...snapshot.plan === null ? [] : [{
9701
+ path: planHistoryFile(chapter, snapshot.pipeline.revision),
9702
+ text: yaml(snapshot.plan)
9703
+ }],
9704
+ {
9705
+ path,
9706
+ text: yaml(plan)
9707
+ },
9708
+ {
9709
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
9710
+ text: yaml(nextPipeline)
9711
+ }
9712
+ ],
9713
+ backup: snapshot.plan !== null,
8203
9714
  data: {
8204
9715
  chapter,
8205
9716
  stage: "P3",
@@ -8217,6 +9728,7 @@ var EngineService = class {
8217
9728
  return this.mutate({
8218
9729
  root: input.root,
8219
9730
  op: "submitDraft",
9731
+ chapter: input.chapter,
8220
9732
  key: () => `draft:${input.chapter}:${sha256(stableJson({
8221
9733
  content: input.content,
8222
9734
  chapter_title: input.chapter_title,
@@ -8242,31 +9754,32 @@ var EngineService = class {
8242
9754
  summary: input.summary
8243
9755
  });
8244
9756
  const chapter = input.chapter;
8245
- const plan = snapshot.plan;
8246
- if (plan === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${chapter} has no usable plan`, "rollback to P2 and submit novel_plan_chapter; the draft was not written");
8247
- const mergedPlan = {
8248
- ...plan,
9757
+ if (snapshot.plan === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${chapter} has no usable plan`, "rollback to P2 and submit novel_plan_chapter; the draft was not written");
9758
+ const draft = {
9759
+ chapter,
8249
9760
  title: input.chapter_title,
8250
9761
  scene_type: input.scene_type,
8251
9762
  narrator: input.narrator,
8252
- summary: input.summary
9763
+ summary: input.summary,
9764
+ content_hash: sha256(input.content),
9765
+ updated_at: this.now().toISOString()
8253
9766
  };
8254
- const planPath = planChapterFile(chapter);
8255
9767
  const nextPipeline = pipelineWith(snapshot, context.revision, {
8256
9768
  stage: "P4",
9769
+ draft,
8257
9770
  blocking: [],
8258
9771
  last_action: action("novel_write_draft", this.now(), sha256(input.content))
8259
9772
  });
8260
9773
  return {
8261
9774
  files: [
9775
+ ...snapshot.text === null ? [] : [{
9776
+ path: chapterHistoryFile(chapter, snapshot.pipeline.revision),
9777
+ text: snapshot.text
9778
+ }],
8262
9779
  {
8263
9780
  path: chapterFile(chapter),
8264
9781
  text: input.content
8265
9782
  },
8266
- {
8267
- path: planPath,
8268
- text: yaml(mergedPlan)
8269
- },
8270
9783
  {
8271
9784
  path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8272
9785
  text: yaml(nextPipeline)
@@ -8287,6 +9800,7 @@ var EngineService = class {
8287
9800
  return this.mutate({
8288
9801
  root: input.root,
8289
9802
  op: "submitReview",
9803
+ chapter: input.chapter,
8290
9804
  key: (snapshot) => `review:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
8291
9805
  dimensions: input.dimensions,
8292
9806
  issues: input.issues,
@@ -8368,13 +9882,21 @@ var EngineService = class {
8368
9882
  last_action: action("novel_review", this.now(), reviewDigest)
8369
9883
  });
8370
9884
  return {
8371
- files: [{
8372
- path: reviewFile(input.chapter),
8373
- text: yaml(review)
8374
- }, {
8375
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8376
- text: yaml(nextPipeline)
8377
- }],
9885
+ files: [
9886
+ ...snapshot.review === null ? [] : [{
9887
+ path: reviewHistoryFile(input.chapter, snapshot.pipeline.revision),
9888
+ text: yaml(snapshot.review)
9889
+ }],
9890
+ {
9891
+ path: reviewFile(input.chapter),
9892
+ text: yaml(review)
9893
+ },
9894
+ {
9895
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
9896
+ text: yaml(nextPipeline)
9897
+ }
9898
+ ],
9899
+ backup: snapshot.review !== null,
8378
9900
  data: {
8379
9901
  chapter: input.chapter,
8380
9902
  round: review.round,
@@ -8391,16 +9913,31 @@ var EngineService = class {
8391
9913
  });
8392
9914
  }
8393
9915
  async submitRevision(input) {
9916
+ const hasContent = typeof input.content === "string" && input.content.trim() !== "";
9917
+ const hasPatch = input.patch !== void 0;
9918
+ if (hasContent === hasPatch) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "novel_revise requires exactly one of content or patch", "send the full revised text, or a patch with expected_hash and unique edits");
9919
+ if (hasPatch) {
9920
+ const patch = input.patch;
9921
+ if (patch === null || typeof patch !== "object" || !Array.isArray(patch.edits) || patch.edits.length === 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "patch.edits must be a non-empty array");
9922
+ if (typeof patch.expected_hash !== "string" || patch.expected_hash.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "patch.expected_hash must be the sha256 of the current manuscript", "read novel_status or the current manuscript, then hash the exact text");
9923
+ for (const [index, edit] of patch.edits.entries()) {
9924
+ if (typeof edit !== "object" || edit === null || typeof edit.old_text !== "string" || edit.old_text === "" || typeof edit.new_text !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}] needs non-empty old_text and a string new_text`);
9925
+ if (edit.occurrence !== void 0 && (!Number.isInteger(edit.occurrence) || edit.occurrence < 1)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}].occurrence must be a positive integer`);
9926
+ }
9927
+ }
8394
9928
  return this.mutate({
8395
9929
  root: input.root,
8396
9930
  op: "submitRevision",
8397
- key: () => `revision:${input.chapter}:${sha256(input.content)}:${sha256(stableJson({
9931
+ chapter: input.chapter,
9932
+ key: () => `revision:${input.chapter}:${sha256(input.content ?? stableJson(input.patch))}:${sha256(stableJson({
8398
9933
  summary: input.summary,
8399
9934
  issues: input.issues,
8400
9935
  proof: input.proof
8401
9936
  }))}`,
8402
9937
  run: async (snapshot, context) => {
8403
9938
  this.assertChapter(snapshot, input.chapter);
9939
+ const revisedContent = input.patch === void 0 ? input.content : applyRevisionPatch(snapshot.text ?? "", input.patch);
9940
+ if (revisedContent.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "revision content must not be empty");
8404
9941
  let patchedReview = null;
8405
9942
  if (snapshot.review !== null) {
8406
9943
  const original = snapshot.review.issues;
@@ -8450,6 +9987,7 @@ var EngineService = class {
8450
9987
  issues
8451
9988
  };
8452
9989
  }
9990
+ const reviewChanged = patchedReview !== null && (snapshot.review === null || stableJson(patchedReview) !== stableJson(snapshot.review));
8453
9991
  if (patchedReview !== null) try {
8454
9992
  narrow("review", patchedReview);
8455
9993
  } catch (error) {
@@ -8457,46 +9995,65 @@ var EngineService = class {
8457
9995
  }
8458
9996
  await this.assertGates(snapshot, "submitRevision", {
8459
9997
  briefStage: briefStageFor("submitRevision", snapshot.pipeline.stage),
8460
- text: input.content,
9998
+ text: revisedContent,
8461
9999
  summary: input.summary,
8462
10000
  summaryProvided: input.summary !== void 0,
8463
10001
  review: patchedReview,
8464
10002
  proof: input.proof
8465
10003
  });
8466
- if (input.summary === void 0 && snapshot.plan?.summary !== void 0) {
8467
- const opening = snapshot.plan.summary.opening;
8468
- if (!openingInFirstTwoParagraphs(input.content, opening)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary omitted, but the stored opening no longer appears in the revised first two paragraphs", "resubmit novel_revise with a summary whose opening quotes the revised text");
10004
+ const inheritedSummary = snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary;
10005
+ if (input.summary === void 0 && inheritedSummary !== void 0) {
10006
+ const opening = inheritedSummary.opening;
10007
+ if (!openingInFirstTwoParagraphs(revisedContent, opening)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "summary omitted, but the stored opening no longer appears in the revised first two paragraphs", "resubmit novel_revise with a summary whose opening quotes the revised text");
8469
10008
  }
8470
- const machine = this.machine(snapshot, input.content, { summary: input.summary ?? snapshot.plan?.summary ?? null });
10009
+ const effectiveSummary = input.summary ?? inheritedSummary;
10010
+ if (effectiveSummary === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "the revised draft has no summary", "resubmit novel_revise with the ten-field summary");
10011
+ const machine = this.machine(snapshot, revisedContent, { summary: effectiveSummary });
10012
+ const baseDraft = snapshot.pipeline.draft ?? {
10013
+ chapter: input.chapter,
10014
+ title: snapshot.plan?.title ?? `第${input.chapter}章`,
10015
+ scene_type: snapshot.plan?.scene_type ?? "日常过渡章",
10016
+ narrator: snapshot.plan?.narrator ?? "",
10017
+ summary: effectiveSummary,
10018
+ content_hash: sha256(snapshot.text ?? ""),
10019
+ updated_at: this.now().toISOString()
10020
+ };
8471
10021
  const nextStage$1 = nextStage(snapshot.pipeline.stage, "submitRevision", 0);
8472
10022
  const nextPipeline = pipelineWith(snapshot, context.revision, {
8473
10023
  stage: nextStage$1,
10024
+ draft: {
10025
+ ...baseDraft,
10026
+ summary: effectiveSummary,
10027
+ content_hash: sha256(revisedContent),
10028
+ updated_at: this.now().toISOString()
10029
+ },
8474
10030
  rounds: {
8475
10031
  ...snapshot.pipeline.rounds,
8476
10032
  revise: snapshot.pipeline.rounds.revise + 1
8477
10033
  },
8478
10034
  blocking: [],
8479
- ...patchedReview === null ? {} : { review_digest: sha256(stableJson(patchedReview)) },
8480
- last_action: action("novel_revise", this.now(), sha256(input.content))
8481
- });
8482
- const planPath = planChapterFile(input.chapter);
8483
- const planText = await context.store.readText(planPath);
8484
- const plan = planText === null ? null : parsePlanYaml(planText, planPath);
8485
- const files = [{
8486
- path: chapterFile(input.chapter),
8487
- text: input.content
8488
- }, {
8489
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8490
- text: yaml(nextPipeline)
8491
- }];
8492
- if (plan !== null && input.summary !== void 0) files.push({
8493
- path: planPath,
8494
- text: yaml({
8495
- ...plan,
8496
- summary: input.summary
8497
- })
10035
+ ...patchedReview === null || !reviewChanged && snapshot.pipeline.review_digest !== void 0 ? {} : { review_digest: sha256(stableJson(patchedReview)) },
10036
+ last_action: action("novel_revise", this.now(), sha256(revisedContent))
8498
10037
  });
8499
- if (patchedReview !== null && patchedReview !== snapshot.review) files.push({
10038
+ const files = [
10039
+ ...snapshot.text === null ? [] : [{
10040
+ path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
10041
+ text: snapshot.text
10042
+ }],
10043
+ ...snapshot.review === null || !reviewChanged ? [] : [{
10044
+ path: reviewHistoryFile(input.chapter, snapshot.pipeline.revision),
10045
+ text: yaml(snapshot.review)
10046
+ }],
10047
+ {
10048
+ path: chapterFile(input.chapter),
10049
+ text: revisedContent
10050
+ },
10051
+ {
10052
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
10053
+ text: yaml(nextPipeline)
10054
+ }
10055
+ ];
10056
+ if (reviewChanged && patchedReview !== null) files.push({
8500
10057
  path: reviewFile(input.chapter),
8501
10058
  text: yaml(patchedReview)
8502
10059
  });
@@ -8518,9 +10075,11 @@ var EngineService = class {
8518
10075
  return this.mutate({
8519
10076
  root: input.root,
8520
10077
  op: "runCheck",
10078
+ chapter: input.chapter,
8521
10079
  key: (snapshot) => `check:${input.chapter}:${sha256(snapshot.text ?? "")}:${sha256(stableJson({
8522
10080
  semantic: input.semantic ?? [],
8523
- scope
10081
+ scope,
10082
+ acknowledge: input.acknowledge ?? []
8524
10083
  }))}`,
8525
10084
  run: async (snapshot, context) => {
8526
10085
  this.assertChapter(snapshot, input.chapter);
@@ -8539,6 +10098,13 @@ var EngineService = class {
8539
10098
  violations: machine.violations.filter((violation) => scope.includes(scopeOfRule(violation.code)))
8540
10099
  };
8541
10100
  const machineBlocking = scopedMachine.violations.filter((violation) => violation.severity === "blocker");
10101
+ const machineAdvisories = scopedMachine.violations.filter((violation) => violation.severity === "advisory");
10102
+ const advisoryCodes = [...new Set(machineAdvisories.map((violation) => violation.code))];
10103
+ const requestedAck = Array.isArray(input.acknowledge) ? input.acknowledge : [];
10104
+ const unknownAck = requestedAck.filter((code) => !advisoryCodes.includes(code));
10105
+ if (unknownAck.length > 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot acknowledge non-advisory finding(s): ${unknownAck.join(", ")}`, `advisory codes in this run: ${advisoryCodes.join(", ") || "none"}; blockers and unknown codes cannot be acknowledged`);
10106
+ const priorAck = snapshot.check !== null && snapshot.check.content_hash === sha256(text) ? snapshot.check.acknowledged ?? [] : [];
10107
+ const acknowledged = [.../* @__PURE__ */ new Set([...priorAck, ...requestedAck])].filter((code) => advisoryCodes.includes(code));
8542
10108
  const semanticResult = evaluateSemantic(text, semantic);
8543
10109
  const verdict = machineBlocking.length === 0 && !semanticResult.failed ? "pass" : "fail";
8544
10110
  const check = {
@@ -8547,10 +10113,12 @@ var EngineService = class {
8547
10113
  scope,
8548
10114
  content_hash: sha256(text),
8549
10115
  machine: {
8550
- violations: scopedMachine.violations,
10116
+ violations: machineBlocking,
10117
+ advisories: machineAdvisories,
8551
10118
  metrics: scopedMachine.metrics
8552
10119
  },
8553
10120
  semantic: { items: semantic },
10121
+ ...acknowledged.length === 0 ? {} : { acknowledged },
8554
10122
  verdict
8555
10123
  };
8556
10124
  try {
@@ -8575,24 +10143,42 @@ var EngineService = class {
8575
10143
  last_action: action("novel_check", this.now(), sha256(text))
8576
10144
  });
8577
10145
  return {
8578
- files: [{
8579
- path: checkFile(input.chapter),
8580
- text: yaml(check)
8581
- }, {
8582
- path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8583
- text: yaml(nextPipeline)
8584
- }],
10146
+ files: [
10147
+ ...snapshot.check === null ? [] : [{
10148
+ path: checkHistoryFile(input.chapter, snapshot.pipeline.revision),
10149
+ text: yaml(snapshot.check)
10150
+ }],
10151
+ {
10152
+ path: checkFile(input.chapter),
10153
+ text: yaml(check)
10154
+ },
10155
+ {
10156
+ path: joinRel(STATE_DIR, STATE_FILES.pipeline),
10157
+ text: yaml(nextPipeline)
10158
+ }
10159
+ ],
10160
+ backup: snapshot.check !== null,
8585
10161
  data: {
8586
10162
  chapter: input.chapter,
8587
10163
  verdict,
8588
- violations: scopedMachine.violations.length,
10164
+ violations: machineBlocking.length,
10165
+ advisories: machineAdvisories.filter((violation) => !acknowledged.includes(violation.code)).length,
10166
+ advisory_codes: advisoryCodes,
10167
+ acknowledged,
8589
10168
  metrics: scopedMachine.metrics,
8590
10169
  stage: nextStage$2
8591
10170
  },
8592
- warnings: [...machineWarnings(scopedMachine), ...verdict === "pass" && !fullScope ? [{
8593
- code: "CHECK_SCOPE_PARTIAL",
8594
- message: `scope ${scope.join("+")} passed, but archive requires content+integrity; the pipeline stays at P6`
8595
- }] : []]
10171
+ warnings: [
10172
+ ...machineWarnings(scopedMachine, acknowledged),
10173
+ ...acknowledged.length === 0 ? [] : [{
10174
+ code: "CHECK_ADVISORY_ACKNOWLEDGED",
10175
+ message: `已记录知悉:${acknowledged.join(", ")};advisory 不阻塞归档`
10176
+ }],
10177
+ ...verdict === "pass" && !fullScope ? [{
10178
+ code: "CHECK_SCOPE_PARTIAL",
10179
+ message: `scope ${scope.join("+")} passed, but archive requires content+integrity; the pipeline stays at P6`
10180
+ }] : []
10181
+ ]
8596
10182
  };
8597
10183
  }
8598
10184
  });
@@ -8607,6 +10193,7 @@ var EngineService = class {
8607
10193
  return this.mutate({
8608
10194
  root: input.root,
8609
10195
  op: "archiveChapter",
10196
+ chapter: input.chapter,
8610
10197
  key: (snapshot) => {
8611
10198
  const archived = snapshot.chapters.chapters.find((record) => record.num === input.chapter);
8612
10199
  return `archive:${input.chapter}:${archived?.content_hash ?? sha256(snapshot.text ?? "")}:${archiveDigest}`;
@@ -8633,7 +10220,8 @@ var EngineService = class {
8633
10220
  this.assertChapter(snapshot, input.chapter);
8634
10221
  this.assertChapterWithinTarget(snapshot, input.chapter, "novel_archive");
8635
10222
  const text = snapshot.text ?? "";
8636
- const summary = snapshot.plan?.summary;
10223
+ const draft = snapshot.pipeline.draft;
10224
+ const summary = draft?.summary ?? snapshot.plan?.summary;
8637
10225
  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");
8638
10226
  if (snapshot.review !== null && snapshot.pipeline.review_digest === void 0) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "the stored review has no review_digest; it predates digest-bound reviews and cannot be archived blindly", "re-run novel_review for this chapter, then re-run the archive");
8639
10227
  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");
@@ -8644,6 +10232,10 @@ var EngineService = class {
8644
10232
  review: snapshot.review,
8645
10233
  proof: snapshot.review?.proof ?? null
8646
10234
  });
10235
+ if (draft !== void 0) {
10236
+ if (draft.chapter !== input.chapter) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `pipeline draft belongs to chapter ${draft.chapter}, but chapter ${input.chapter} is being archived`, "re-open the intended chapter or resubmit the draft before archiving");
10237
+ if (draft.content_hash !== sha256(text)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the manuscript changed after its draft summary was written; the summary and title cannot be sealed against this text", "restore the manuscript to the draft version, or call novel_write_draft / novel_revise again before archiving");
10238
+ }
8647
10239
  if (text === "") throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${input.chapter} has no draft`);
8648
10240
  const archiveBlockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
8649
10241
  if (archiveBlockers.length > 0) throw new EngineBlockedError(ERROR_CODES.GATE_BLOCKED, `archiveChapter blocked by ${archiveBlockers.length} machine finding(s), first: ${archiveBlockers[0].code}`, archiveBlockers.map((violation) => ({
@@ -8659,7 +10251,7 @@ var EngineService = class {
8659
10251
  const metrics = countWordMetrics(text);
8660
10252
  const score = snapshot.review?.total ?? 0;
8661
10253
  const chapter = input.chapter;
8662
- const storedTitle = stripTitlePrefix(snapshot.plan?.title ?? "");
10254
+ const storedTitle = stripTitlePrefix(snapshot.pipeline.draft?.title ?? snapshot.plan?.title ?? "");
8663
10255
  const record = {
8664
10256
  num: chapter,
8665
10257
  title: storedTitle === "" ? `第${chapter}章` : storedTitle,
@@ -8667,13 +10259,16 @@ var EngineService = class {
8667
10259
  word_count: metrics.total_word_count,
8668
10260
  dialogue_lines: metrics.dialogue_lines,
8669
10261
  dialogue_ratio: metrics.dialogue_ratio,
8670
- scene_type: snapshot.plan?.scene_type ?? "日常过渡章",
8671
- narrator: snapshot.plan?.narrator ?? "",
10262
+ scene_type: snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? "日常过渡章",
10263
+ narrator: snapshot.pipeline.draft?.narrator ?? snapshot.plan?.narrator ?? "",
8672
10264
  score,
8673
10265
  status: "reviewed",
8674
10266
  content_hash: sha256(text),
8675
10267
  summary
8676
10268
  };
10269
+ const roleHints = {};
10270
+ for (const character of snapshot.worldbook.characters) if (character.role !== "") roleHints[character.name] = character.role;
10271
+ for (const character of snapshot.worldPlan?.characters ?? []) if (character.role !== "") roleHints[character.name] = character.role;
8677
10272
  const ledger = applyStateDelta({
8678
10273
  chapter,
8679
10274
  text,
@@ -8685,11 +10280,15 @@ var EngineService = class {
8685
10280
  foreshadows: snapshot.foreshadows,
8686
10281
  timeline: snapshot.timeline,
8687
10282
  relationships: snapshot.relationships,
8688
- narrator: snapshot.plan?.narrator ?? null
10283
+ narrator: snapshot.pipeline.draft?.narrator ?? snapshot.plan?.narrator ?? null,
10284
+ roleHints
8689
10285
  });
8690
10286
  record.assigned_ids = ledger.assigned;
8691
10287
  record.archive_digest = archiveDigest;
8692
10288
  const archivedAt = this.now().toISOString();
10289
+ record.rounds = await this.lifetimeRounds(snapshot, chapter);
10290
+ record.final_check = snapshot.check?.verdict ?? "fail";
10291
+ record.archived_at = archivedAt;
8693
10292
  const outline = markOutlineArchived(snapshot.outline, record, archivedAt, snapshot.project.targets.words_per_chapter);
8694
10293
  this.assertOutlineWritable(snapshot, outline, "novel_archive");
8695
10294
  const foreshadows = ledger.foreshadows;
@@ -8730,6 +10329,7 @@ var EngineService = class {
8730
10329
  blocking: [],
8731
10330
  last_action: action("novel_archive", this.now(), sha256(text))
8732
10331
  });
10332
+ delete nextPipeline.draft;
8733
10333
  assertArchiveDocuments([
8734
10334
  ["chapters", chapters],
8735
10335
  ["characters", ledger.characters],
@@ -8742,12 +10342,18 @@ var EngineService = class {
8742
10342
  ]);
8743
10343
  const archiveDigests = await this.readArchiveDigests(context.store, snapshot);
8744
10344
  archiveDigests[String(chapter)] = archiveDigest;
10345
+ const planText = await context.store.readText(planChapterFile(chapter));
10346
+ if (planText === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `chapter ${chapter} has no stored plan; archiving would not be able to prove it later`, "restore the plan from backups/ before archiving");
8745
10347
  return {
8746
10348
  files: [
8747
10349
  {
8748
10350
  path: chapterFile(chapter),
8749
10351
  text
8750
10352
  },
10353
+ {
10354
+ path: planChapterFile(chapter),
10355
+ text: planText
10356
+ },
8751
10357
  {
8752
10358
  path: joinRel(STATE_DIR, STATE_FILES.chapters),
8753
10359
  text: yaml(chapters)
@@ -8818,7 +10424,8 @@ var EngineService = class {
8818
10424
  if (typeof input.path !== "string" || input.path.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend path must be a non-empty string");
8819
10425
  if (typeof input.reason !== "string" || input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason");
8820
10426
  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");
8821
- const resolvedRoot = await this.resolveRoot(input.root);
10427
+ const selection = await this.resolveRoot(input.root);
10428
+ const resolvedRoot = selection.root;
8822
10429
  const store = this.storeFor(resolvedRoot);
8823
10430
  const absolute = store.path(input.path);
8824
10431
  const kind = Object.hasOwn(MUTABLE_STATE, input.path) ? MUTABLE_STATE[input.path] : void 0;
@@ -8829,11 +10436,10 @@ var EngineService = class {
8829
10436
  if (await this.stateDocIsUnreadable(store, path, docKind)) repairPaths.push(path);
8830
10437
  }
8831
10438
  return this.mutate({
8832
- root: resolvedRoot,
10439
+ selection,
8833
10440
  op: "amend",
8834
10441
  reason: input.reason,
8835
10442
  ...repairPaths.length === 0 ? {} : { repairPaths },
8836
- key: (snapshot) => `amend:${input.path}:${snapshot.pipeline.revision}`,
8837
10443
  run: async (snapshot, context) => {
8838
10444
  if (input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason", "state the intent so the journal explains the change");
8839
10445
  if (kind === "pipeline") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "pipeline cannot be amended directly", "use novel_rollback or the stage operations");
@@ -8966,8 +10572,9 @@ var EngineService = class {
8966
10572
  return this.mutate({
8967
10573
  root: input.root,
8968
10574
  op: "rollback",
10575
+ chapter: input.chapter,
8969
10576
  reason: input.reason,
8970
- key: () => `rollback:${input.chapter}:${input.toStage}`,
10577
+ key: (snapshot) => `rollback:${input.chapter}:${input.toStage}:${sha256(snapshot.text ?? "")}`,
8971
10578
  run: async (snapshot, context) => {
8972
10579
  this.assertChapter(snapshot, input.chapter);
8973
10580
  if (!Object.prototype.hasOwnProperty.call(ROUND_TRANSITIONS, input.toStage)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown rollback target "${String(input.toStage)}"`, `allowed targets: ${Object.keys(ROUND_TRANSITIONS).join(", ")}`);
@@ -8985,15 +10592,31 @@ var EngineService = class {
8985
10592
  blocking: [],
8986
10593
  last_action: action("novel_rollback", this.now(), shortDigest(input.reason ?? ""))
8987
10594
  });
10595
+ delete nextPipeline.draft;
10596
+ if (stage !== "idle" && stage !== "P2" && snapshot.pipeline.draft !== void 0) nextPipeline.draft = snapshot.pipeline.draft;
8988
10597
  const files = [{
8989
10598
  path: joinRel(STATE_DIR, STATE_FILES.pipeline),
8990
10599
  text: yaml(nextPipeline)
8991
10600
  }];
8992
10601
  const current = await context.store.readText(chapterFile(input.chapter));
8993
- if (current !== null) files.push({
8994
- path: deprecatedChapterFile(input.chapter),
8995
- text: current
8996
- });
10602
+ if (current !== null) {
10603
+ files.push({
10604
+ path: chapterFile(input.chapter),
10605
+ text: current
10606
+ });
10607
+ files.push({
10608
+ path: deprecatedChapterFile(input.chapter),
10609
+ text: current
10610
+ });
10611
+ files.push({
10612
+ path: chapterHistoryFile(input.chapter, snapshot.pipeline.revision),
10613
+ text: current
10614
+ });
10615
+ files.push({
10616
+ path: deprecatedHistoryFile(input.chapter, snapshot.pipeline.revision),
10617
+ text: current
10618
+ });
10619
+ }
8997
10620
  return {
8998
10621
  files,
8999
10622
  backup: true,
@@ -9010,15 +10633,8 @@ var EngineService = class {
9010
10633
  if (input.before !== void 0 && typeof input.before !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "diff before must be a string");
9011
10634
  if (input.after !== void 0 && typeof input.after !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "diff after must be a string");
9012
10635
  if (input.before !== void 0 && input.after !== void 0) {
9013
- let chapter = input.chapter;
9014
- let revision = 1;
9015
- if (chapter === void 0) try {
9016
- const snapshot = await this.load(input.root);
9017
- chapter = snapshot.pipeline.chapter;
9018
- revision = snapshot.pipeline.revision;
9019
- } catch {
9020
- chapter = 0;
9021
- }
10636
+ const chapter = input.chapter ?? 0;
10637
+ const revision = 1;
9022
10638
  const report = diffLines(input.before, input.after);
9023
10639
  return {
9024
10640
  data: {
@@ -9074,8 +10690,21 @@ var EngineService = class {
9074
10690
  });
9075
10691
  const requested = input.limit ?? 50;
9076
10692
  const limit = Number.isFinite(requested) ? Math.max(0, Math.min(1e3, Math.floor(requested))) : 50;
10693
+ if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
10694
+ if (input.cursor !== void 0 && (!Number.isInteger(input.cursor) || input.cursor < 0)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "audit cursor must be a non-negative integer");
10695
+ if (input.since !== void 0 && Number.isNaN(Date.parse(input.since))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "audit since must be an ISO timestamp");
10696
+ if (input.until !== void 0 && Number.isNaN(Date.parse(input.until))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "audit until must be an ISO timestamp");
10697
+ const filtered = rows.filter((row) => {
10698
+ if (input.chapter !== void 0 && row.chapter !== input.chapter) return false;
10699
+ if (input.op !== void 0 && row.op !== input.op) return false;
10700
+ if (input.status !== void 0 && row.status !== input.status) return false;
10701
+ if (input.since !== void 0 && Date.parse(row.at) < Date.parse(input.since)) return false;
10702
+ if (input.until !== void 0 && Date.parse(row.at) > Date.parse(input.until)) return false;
10703
+ if (input.cursor !== void 0 && (row.seq ?? 0) <= input.cursor) return false;
10704
+ return true;
10705
+ });
9077
10706
  return {
9078
- data: limit === 0 ? [] : rows.slice(-limit),
10707
+ data: limit === 0 ? [] : input.cursor === void 0 ? filtered.slice(-limit) : filtered.slice(0, limit),
9079
10708
  revision: snapshot.pipeline.revision,
9080
10709
  warnings
9081
10710
  };
@@ -9262,20 +10891,162 @@ var EngineService = class {
9262
10891
  async panelGates(input = {}) {
9263
10892
  return projectGates(await this.panelSnapshot(input.root));
9264
10893
  }
9265
- /** One snapshot, three projections: the panel never mixes revisions. */
10894
+ /**
10895
+ * Build the read-only panel payload. The query root is accepted only when it
10896
+ * is registered, active or the configured root; arbitrary paths never reach
10897
+ * the filesystem from the panel route.
10898
+ */
9266
10899
  async panelBundle(input = {}) {
9267
- const snapshot = await this.panelSnapshot(input.root);
10900
+ const registry = await this.registry().read();
10901
+ const active = registry.active;
10902
+ const rows = /* @__PURE__ */ new Map();
10903
+ const add = (option) => {
10904
+ if (!rows.has(option.root)) rows.set(option.root, option);
10905
+ };
10906
+ const optionFor = async (root, registered, fallbackTitle, expectedCreatedAt) => {
10907
+ const inspection = await inspectProject(root);
10908
+ const state = inspection.state === "ok" ? "ok" : inspection.state === "damaged" ? "damaged" : inspection.state === "unreadable" ? "unreadable" : "missing";
10909
+ const sameIdentity = expectedCreatedAt === void 0 || expectedCreatedAt === "" || inspection.created_at === expectedCreatedAt;
10910
+ const title = inspection.state === "ok" && inspection.title !== "" && sameIdentity ? inspection.title : fallbackTitle !== void 0 && fallbackTitle !== "" ? fallbackTitle : inspection.title;
10911
+ return {
10912
+ root: inspection.root,
10913
+ title,
10914
+ active: active === inspection.root,
10915
+ registered,
10916
+ state
10917
+ };
10918
+ };
10919
+ for (const entry of registry.projects) add(await optionFor(entry.root, true, entry.title, entry.created_at));
10920
+ if (active !== null && !rows.has(active)) {
10921
+ const known = registry.projects.find((entry) => entry.root === active);
10922
+ add(await optionFor(active, known !== void 0, known?.title, known?.created_at));
10923
+ }
10924
+ const configured = this.defaultRoot();
10925
+ if (!rows.has(configured)) {
10926
+ const option = await optionFor(configured, false);
10927
+ if (option.state !== "missing" || active === configured) add(option);
10928
+ }
10929
+ const selectedRoot = input.root === void 0 ? void 0 : resolve(input.root);
10930
+ if (selectedRoot !== void 0 && !rows.has(selectedRoot) && selectedRoot === configured) add(await optionFor(configured, false));
10931
+ const projects = [...rows.values()].sort((left, right) => compareText(left.root, right.root));
10932
+ let selection;
10933
+ if (selectedRoot !== void 0) {
10934
+ if (!rows.has(selectedRoot)) return {
10935
+ projects,
10936
+ selection: {
10937
+ root: selectedRoot,
10938
+ source: "query"
10939
+ },
10940
+ missing: {
10941
+ root: selectedRoot,
10942
+ title: "(未登记的项目)",
10943
+ code: ERROR_CODES.PROJECT_NOT_FOUND,
10944
+ reason: "not_registered",
10945
+ hint: "用 /novel scan --register 或 /novel admin novel_registry_register 登记后刷新;面板不会读取任意路径。"
10946
+ }
10947
+ };
10948
+ selection = {
10949
+ root: selectedRoot,
10950
+ source: "query"
10951
+ };
10952
+ } else if (active !== null) selection = {
10953
+ root: active,
10954
+ source: "active"
10955
+ };
10956
+ else if (registry.projects.length === 1) selection = {
10957
+ root: registry.projects[0].root,
10958
+ source: "single"
10959
+ };
10960
+ else if (rows.has(configured) && !registry.projects.some((entry) => entry.root === configured)) selection = {
10961
+ root: configured,
10962
+ source: "config"
10963
+ };
10964
+ else selection = {
10965
+ root: null,
10966
+ source: "none"
10967
+ };
10968
+ if (selection.root === null) return {
10969
+ projects,
10970
+ selection,
10971
+ ...projects.length === 0 ? {
10972
+ empty: true,
10973
+ error: "还没有项目:调用 novel_init 创建。"
10974
+ } : {}
10975
+ };
10976
+ const option = rows.get(selection.root);
10977
+ const inspection = await inspectProject(selection.root);
10978
+ if (option.state === "missing" || option.state === "unreadable" || inspection.state === "not-project") return {
10979
+ projects,
10980
+ selection,
10981
+ missing: {
10982
+ root: selection.root,
10983
+ title: option.title,
10984
+ code: inspection.state === "not-project" ? ERROR_CODES.PROJECT_NOT_FOUND : ERROR_CODES.PROJECT_MISSING,
10985
+ reason: inspection.state === "not-project" ? "not-project" : inspection.state === "unreadable" ? "unreadable" : "missing",
10986
+ hint: inspection.state === "not-project" ? "目录存在,但没有 novel.yaml 或项目状态;请恢复项目定义,或改选其他项目。" : inspection.state === "unreadable" ? "目录不可读;请修复权限或 IO 错误后刷新,登记行和面板偏好会保留。" : "项目目录不在了;请恢复目录或挂载后刷新,登记行和面板偏好会保留。"
10987
+ }
10988
+ };
10989
+ let snapshot;
10990
+ try {
10991
+ snapshot = await this.panelSnapshot(selection.root);
10992
+ } catch (error) {
10993
+ if (error instanceof EngineError && (error.code === ERROR_CODES.PROJECT_MISSING || error.code === ERROR_CODES.PROJECT_NOT_FOUND)) return {
10994
+ projects,
10995
+ selection,
10996
+ missing: {
10997
+ root: selection.root,
10998
+ title: option.title,
10999
+ code: error.code === ERROR_CODES.PROJECT_MISSING ? ERROR_CODES.PROJECT_MISSING : ERROR_CODES.PROJECT_NOT_FOUND,
11000
+ reason: "missing",
11001
+ hint: error.hint ?? "项目目录不在了;请恢复目录或挂载后刷新,登记行和面板偏好会保留。"
11002
+ }
11003
+ };
11004
+ throw error;
11005
+ }
11006
+ const summary = projectSummary(snapshot);
11007
+ const resume = summary.next === void 0 ? void 0 : { text: formatResumePrompt({
11008
+ title: summary.title,
11009
+ root: summary.root,
11010
+ chapter: summary.chapter,
11011
+ stage: summary.stage,
11012
+ nextTool: summary.next.tool
11013
+ }) };
9268
11014
  return {
9269
- summary: projectSummary(snapshot),
11015
+ projects,
11016
+ selection,
11017
+ summary,
9270
11018
  chapters: projectChapters(snapshot),
9271
- gates: projectGates(snapshot)
11019
+ gates: projectGates(snapshot),
11020
+ ...resume === void 0 ? {} : { resume }
11021
+ };
11022
+ }
11023
+ /** Read-only resume text shared with the panel copy button. */
11024
+ async resumePrompt(input = {}) {
11025
+ if (this.caller()?.sessionId === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, "resume requires an Agent session", "run /novel resume from an Agent conversation");
11026
+ const selection = await this.resolveRoot(input.root);
11027
+ const snapshot = await this.loadAt(selection);
11028
+ const status = await this.statusFor(snapshot, snapshot.pipeline.chapter);
11029
+ const nextTool = status.data.next?.tool;
11030
+ return {
11031
+ data: {
11032
+ text: nextTool === void 0 ? null : formatResumePrompt({
11033
+ title: status.data.project.title,
11034
+ root: status.data.project.root,
11035
+ chapter: status.data.chapter,
11036
+ stage: status.data.stage,
11037
+ nextTool
11038
+ }),
11039
+ selection: status.data.selection
11040
+ },
11041
+ revision: status.revision,
11042
+ warnings: []
9272
11043
  };
9273
11044
  }
9274
11045
  async stats(input = {}) {
9275
11046
  const snapshot = await this.load(input.root);
9276
11047
  const totalWords = snapshot.chapters.chapters.reduce((total, chapter) => total + chapter.word_count, 0);
9277
11048
  const scores = snapshot.chapters.chapters.map((chapter) => chapter.score);
9278
- const outline = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length);
11049
+ const outline = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length, snapshot.project.targets.total_chapters, snapshot.chapters.chapters.map((record) => record.num));
9279
11050
  const counts = worldbookCounts(snapshot.worldbook);
9280
11051
  return {
9281
11052
  totalWords,
@@ -9286,51 +11057,299 @@ var EngineService = class {
9286
11057
  lastScore: scores.length === 0 ? 0 : scores[scores.length - 1],
9287
11058
  openForeshadows: snapshot.foreshadows.items.filter((item) => item.status !== "resolved").length,
9288
11059
  plannedChapters: outline.planned,
9289
- outlineCoverage: outline.coverage,
11060
+ totalCards: outline.total,
11061
+ outlineCoverage: outline.plannedCoverage,
11062
+ archivedCoverage: outline.archivedCoverage,
11063
+ missingCards: outline.missingCards,
9290
11064
  blueprintReady: bookPlanPresent(snapshot.outline.book),
9291
- worldbookEntries: counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory,
11065
+ worldbookEntries: counts.characters + counts.locations + counts.factions + counts.rules + counts.glossary + counts.backstory + counts.facts,
9292
11066
  strictOutline: snapshot.outline.book.strict,
9293
11067
  targetChapters: snapshot.project.targets.total_chapters
9294
11068
  };
9295
11069
  }
9296
11070
  registry() {
9297
- return new ProjectRegistry(new FsStore(this.config.stateDir === "" ? join(homedir(), ".dsh", "novel-write-engine") : this.config.stateDir));
11071
+ return new ProjectRegistry(this.stateStore());
9298
11072
  }
9299
- /** List known projects (convenience only; a project opens without a row). */
11073
+ /** List known projects with a cheap directory state for the panel/command. */
9300
11074
  async listProjects() {
9301
11075
  const document = await this.registry().read();
11076
+ const projects = await Promise.all(document.projects.map(async (entry) => {
11077
+ const inspection = await inspectProject(entry.root);
11078
+ const sameIdentity = entry.created_at === void 0 || entry.created_at === "" || inspection.created_at === entry.created_at;
11079
+ return {
11080
+ ...entry,
11081
+ ...inspection.state === "ok" && inspection.title !== "" && sameIdentity ? { title: inspection.title } : {},
11082
+ state: inspection.state
11083
+ };
11084
+ }));
9302
11085
  return {
9303
11086
  active: document.active,
9304
- projects: document.projects
11087
+ projects
9305
11088
  };
9306
11089
  }
9307
- /** Record the current project in the registry. */
11090
+ /**
11091
+ * Record an explicit project row. An unchanged duplicate is a no-op; a path
11092
+ * reused by another project updates the row and reports `replaced`.
11093
+ */
9308
11094
  async registerProject(input = {}) {
9309
- const snapshot = await this.load(input.root);
9310
- const document = await this.registry().register({
9311
- root: snapshot.root,
9312
- title: snapshot.project.title
9313
- });
9314
- return {
9315
- active: document.active,
9316
- projects: document.projects
11095
+ const selection = input.root === void 0 ? await this.resolveRoot(void 0) : {
11096
+ root: this.resolveProjectPath(input.root),
11097
+ source: "explicit"
9317
11098
  };
11099
+ const snapshot = await this.loadAt(selection);
11100
+ this.assertProjectHealthy(snapshot, "novel_registry_register");
11101
+ return this.withMetadataLock(async () => {
11102
+ const registry = this.registry();
11103
+ const document = await registry.read();
11104
+ const previous = document.projects.find((entry) => entry.root === snapshot.root);
11105
+ const replaced = previous !== void 0 && previous.created_at !== void 0 && previous.created_at !== "" && previous.created_at !== snapshot.project.created_at;
11106
+ if (!replaced && previous !== void 0 && previous.title === snapshot.project.title && (previous.created_at ?? "") === (snapshot.project.created_at ?? "")) return {
11107
+ active: document.active,
11108
+ projects: document.projects,
11109
+ replaced: false
11110
+ };
11111
+ const next = await registry.register({
11112
+ root: snapshot.root,
11113
+ title: snapshot.project.title,
11114
+ created_at: snapshot.project.created_at
11115
+ });
11116
+ return {
11117
+ active: next.active,
11118
+ projects: next.projects,
11119
+ replaced
11120
+ };
11121
+ });
9318
11122
  }
9319
- /** Choose the default project for future convenience calls. */
11123
+ /** Choose the panel/default project. This never binds the current session. */
9320
11124
  async setActiveProject(input) {
9321
- const document = await this.registry().setActive(resolve(input.root));
9322
- this.lastRoot = document.active === null ? null : resolve(document.active);
9323
- return { active: document.active };
11125
+ const selection = {
11126
+ root: this.resolveProjectPath(input.root),
11127
+ source: "explicit"
11128
+ };
11129
+ const snapshot = await this.loadAt(selection);
11130
+ this.assertProjectHealthy(snapshot, "novel_registry_activate");
11131
+ return this.withMetadataLock(async () => {
11132
+ const registry = this.registry();
11133
+ const previous = (await registry.read()).projects.find((entry) => entry.root === snapshot.root);
11134
+ const replaced = previous !== void 0 && previous.created_at !== void 0 && previous.created_at !== "" && previous.created_at !== snapshot.project.created_at;
11135
+ await registry.register({
11136
+ root: snapshot.root,
11137
+ title: snapshot.project.title,
11138
+ created_at: snapshot.project.created_at
11139
+ });
11140
+ return {
11141
+ active: (await registry.setActive(snapshot.root)).active,
11142
+ replaced
11143
+ };
11144
+ });
9324
11145
  }
9325
- /** Forget a registry row; the project directory is never touched. */
11146
+ /** Forget a registry row; the project directory and bindings are never touched. */
9326
11147
  async forgetProject(input) {
9327
- const document = await this.registry().forget(resolve(input.root));
9328
- if (this.lastRoot === resolve(input.root)) this.lastRoot = document.active === null ? null : resolve(document.active);
11148
+ return this.withMetadataLock(async () => {
11149
+ const document = await this.registry().forget(this.resolveProjectPath(input.root));
11150
+ return {
11151
+ active: document.active,
11152
+ projects: document.projects
11153
+ };
11154
+ });
11155
+ }
11156
+ /**
11157
+ * Bounded workspace scan. Normal mode is read-only; `register` updates only
11158
+ * healthy, newly discovered rows, never active or session bindings.
11159
+ */
11160
+ async scanProjects(input) {
11161
+ const cwd = resolve(input.cwd);
11162
+ const scan = await scanWorkspace(cwd);
11163
+ const before = await this.registry().read();
11164
+ const registeredRoots = new Set(before.projects.map((entry) => entry.root));
11165
+ const candidates = scan.candidates.map((candidate) => ({
11166
+ ...candidate,
11167
+ registered: registeredRoots.has(candidate.root)
11168
+ }));
11169
+ if (input.register !== true) return {
11170
+ cwd,
11171
+ candidates,
11172
+ truncated: scan.truncated,
11173
+ added: [],
11174
+ already: [],
11175
+ skipped: [],
11176
+ identity_changed: []
11177
+ };
11178
+ const loaded = /* @__PURE__ */ new Map();
11179
+ const skipped = [];
11180
+ for (const candidate of candidates) {
11181
+ if (candidate.state !== "ok") {
11182
+ skipped.push({
11183
+ root: candidate.root,
11184
+ reason: candidate.state === "damaged" ? `damaged: ${candidate.error ?? "novel.yaml is missing/unreadable"}` : candidate.state === "unreadable" ? `unreadable: ${candidate.error ?? "permission or IO error"}` : `not a project (${candidate.state})`
11185
+ });
11186
+ continue;
11187
+ }
11188
+ if (registeredRoots.has(candidate.root)) continue;
11189
+ try {
11190
+ const snapshot = await this.loadAt({
11191
+ root: candidate.root,
11192
+ source: "explicit"
11193
+ });
11194
+ if (snapshot.projectError !== null) {
11195
+ skipped.push({
11196
+ root: candidate.root,
11197
+ reason: `damaged: ${snapshot.projectError}`
11198
+ });
11199
+ continue;
11200
+ }
11201
+ loaded.set(candidate.root, snapshot);
11202
+ } catch (error) {
11203
+ skipped.push({
11204
+ root: candidate.root,
11205
+ reason: errorMessage(error)
11206
+ });
11207
+ }
11208
+ }
11209
+ const added = [];
11210
+ const already = [];
11211
+ const identityChanged = [];
11212
+ await this.withMetadataLock(async () => {
11213
+ const registry = this.registry();
11214
+ const document = await registry.read();
11215
+ for (const candidate of candidates) {
11216
+ if (candidate.state !== "ok") continue;
11217
+ const existing = document.projects.find((entry) => entry.root === candidate.root);
11218
+ if (existing !== void 0) {
11219
+ candidate.registered = true;
11220
+ already.push(candidate.root);
11221
+ const previousCreatedAt = existing.created_at ?? "";
11222
+ const currentCreatedAt = candidate.created_at ?? "";
11223
+ if (previousCreatedAt !== "" && previousCreatedAt !== currentCreatedAt) {
11224
+ identityChanged.push(candidate.root);
11225
+ candidate.identity_changed = true;
11226
+ candidate.reason = "registered under a different created_at (or the replacement has none); not overwritten. Run explicit register/activate to confirm the replacement.";
11227
+ continue;
11228
+ }
11229
+ if (candidate.title !== "" && (existing.title !== candidate.title || previousCreatedAt !== currentCreatedAt)) try {
11230
+ await registry.register({
11231
+ root: candidate.root,
11232
+ title: candidate.title,
11233
+ created_at: candidate.created_at
11234
+ });
11235
+ } catch (error) {
11236
+ skipped.push({
11237
+ root: candidate.root,
11238
+ reason: `registry update failed: ${errorMessage(error)}`
11239
+ });
11240
+ }
11241
+ continue;
11242
+ }
11243
+ const snapshot = loaded.get(candidate.root);
11244
+ if (snapshot === void 0) continue;
11245
+ try {
11246
+ await registry.register({
11247
+ root: snapshot.root,
11248
+ title: snapshot.project.title,
11249
+ created_at: snapshot.project.created_at
11250
+ });
11251
+ added.push(candidate.root);
11252
+ candidate.registered = true;
11253
+ candidate.title = snapshot.project.title;
11254
+ candidate.created_at = snapshot.project.created_at;
11255
+ } catch (error) {
11256
+ skipped.push({
11257
+ root: candidate.root,
11258
+ reason: `registry write failed: ${errorMessage(error)}`
11259
+ });
11260
+ }
11261
+ }
11262
+ });
9329
11263
  return {
9330
- active: document.active,
9331
- projects: document.projects
11264
+ cwd,
11265
+ candidates,
11266
+ truncated: scan.truncated,
11267
+ added,
11268
+ already,
11269
+ skipped,
11270
+ identity_changed: identityChanged
11271
+ };
11272
+ }
11273
+ /** Current session binding, or the inferred fallback when none is set. */
11274
+ async currentProject() {
11275
+ const caller = this.caller();
11276
+ if (caller?.sessionId === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, "currentProject requires an Agent session", "run /novel current from an Agent conversation");
11277
+ const binding = await this.bindings().get(caller.sessionId);
11278
+ if (binding !== void 0) {
11279
+ const inspection = await inspectProject(binding.root).catch(() => null);
11280
+ return {
11281
+ selection: {
11282
+ root: binding.root,
11283
+ source: "session",
11284
+ title: binding.title,
11285
+ created_at: binding.project_created_at
11286
+ },
11287
+ bound: true,
11288
+ ...inspection === null ? {} : { state: inspection.state }
11289
+ };
11290
+ }
11291
+ try {
11292
+ return {
11293
+ selection: await this.resolveRoot(void 0),
11294
+ bound: false
11295
+ };
11296
+ } catch (error) {
11297
+ return {
11298
+ selection: null,
11299
+ bound: false,
11300
+ error: errorMessage(error)
11301
+ };
11302
+ }
11303
+ }
11304
+ /** Explicitly switch this session; failure leaves the old binding untouched. */
11305
+ async useProject(input) {
11306
+ const caller = this.caller();
11307
+ if (caller?.sessionId === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, "useProject requires an Agent session", "run /novel use from an Agent conversation");
11308
+ const target = input.target.trim().replace(/^(["'])(.*)\1$/u, "$2");
11309
+ if (target === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "use requires a path, title or \".\"");
11310
+ let root;
11311
+ if (target === ".") {
11312
+ if (caller.cwd === void 0 || caller.cwd === "") throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, "use \".\" requires a working directory", "run the command from a project workspace, or pass an explicit path");
11313
+ const found = await discoverWorkspace(caller.cwd);
11314
+ if (found.kind === "many") throw ambiguousError(found.roots.map((candidate) => ({ root: candidate })));
11315
+ if (found.kind === "none") throw projectNotFoundError(caller.cwd);
11316
+ root = found.root;
11317
+ } else {
11318
+ const candidate = resolve(caller.cwd ?? process.cwd(), target);
11319
+ const inspection = await inspectProject(candidate).catch(() => null);
11320
+ if (inspection !== null && inspection.state !== "not-project" && inspection.state !== "missing") root = candidate;
11321
+ else {
11322
+ const matches = (await this.registry().read()).projects.filter((entry) => entry.title === target);
11323
+ if (matches.length > 1) throw new EngineInputError(ERROR_CODES.PROJECT_AMBIGUOUS, `multiple registered projects are named "${target}":\n${describeCandidates(matches)}`, "use the full project path instead of the title");
11324
+ root = matches[0]?.root;
11325
+ }
11326
+ }
11327
+ if (root === void 0) throw new EngineInputError(ERROR_CODES.PROJECT_NOT_FOUND, `no registered project or readable directory matches "${target}"`, "run /novel projects or /novel scan to see available projects");
11328
+ const selection = {
11329
+ root,
11330
+ source: "explicit"
11331
+ };
11332
+ const snapshot = await this.loadAt(selection);
11333
+ this.assertProjectHealthy(snapshot, "novel_use");
11334
+ await this.bindSelection(snapshot.root, snapshot.project.title, snapshot.project.created_at, "command");
11335
+ await this.autoRegister(snapshot);
11336
+ return {
11337
+ selection: {
11338
+ root: snapshot.root,
11339
+ source: "explicit",
11340
+ title: snapshot.project.title,
11341
+ created_at: snapshot.project.created_at
11342
+ },
11343
+ title: snapshot.project.title,
11344
+ created_at: snapshot.project.created_at
9332
11345
  };
9333
11346
  }
11347
+ /** Clear only this session's choice; never touches a project or registry row. */
11348
+ async unbindProject() {
11349
+ const caller = this.caller();
11350
+ if (caller?.sessionId === void 0) throw new EngineInputError(ERROR_CODES.CONFIG_INVALID, "unbindProject requires an Agent session", "run /novel unbind from an Agent conversation");
11351
+ return { cleared: (await this.withMetadataLock(() => this.bindings().clear(caller.sessionId))).changed };
11352
+ }
9334
11353
  /**
9335
11354
  * Import one ledger from a settings pack. Always previews with a diff; a
9336
11355
  * dry run never writes, and an apply is a normal transaction.
@@ -9448,7 +11467,7 @@ var EngineService = class {
9448
11467
  throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `state/${STATE_FILES.chapters} is unreadable; ${operation} cannot preserve archived outline cards`, `repair state/${STATE_FILES.chapters} with novel_amend before ${operation}`);
9449
11468
  }
9450
11469
  bookStatus(snapshot) {
9451
- const summary = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length);
11470
+ const summary = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length, snapshot.project.targets.total_chapters, snapshot.chapters.chapters.map((record) => record.num));
9452
11471
  const counts = worldbookCounts(snapshot.worldbook);
9453
11472
  return {
9454
11473
  premise: snapshot.outline.book.premise,
@@ -9456,10 +11475,15 @@ var EngineService = class {
9456
11475
  strict: snapshot.outline.book.strict,
9457
11476
  outline: {
9458
11477
  present: snapshot.outlinePresent && summary.present,
11478
+ target: summary.target,
9459
11479
  total: summary.total,
9460
11480
  planned: summary.planned,
9461
11481
  archived: summary.archived,
11482
+ missing_cards: summary.missingCards,
11483
+ planned_coverage: summary.plannedCoverage,
11484
+ archived_coverage: summary.archivedCoverage,
9462
11485
  coverage: summary.coverage,
11486
+ card_coverage: summary.cardCoverage,
9463
11487
  volumes: summary.volumes,
9464
11488
  current: outlineCard(snapshot.outline, snapshot.pipeline.chapter)
9465
11489
  },
@@ -9470,12 +11494,14 @@ var EngineService = class {
9470
11494
  factions: counts.factions,
9471
11495
  rules: counts.rules,
9472
11496
  glossary: counts.glossary,
9473
- backstory: counts.backstory
11497
+ backstory: counts.backstory,
11498
+ facts: counts.facts
9474
11499
  }
9475
11500
  };
9476
11501
  }
9477
11502
  async panelSnapshot(root) {
9478
- const snapshot = await this.load(root);
11503
+ const selection = await this.resolveRoot(root);
11504
+ const snapshot = await this.loadAt(selection);
9479
11505
  const archived = snapshot.chapters.chapters;
9480
11506
  const archivedCurrent = archived.some((chapter) => chapter.num === snapshot.pipeline.chapter);
9481
11507
  const target = snapshot.project.targets.total_chapters;
@@ -9483,7 +11509,7 @@ var EngineService = class {
9483
11509
  const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
9484
11510
  const journal = await readJournal(this.storeFor(snapshot.root));
9485
11511
  const journalBlocking = await this.journalBlocking(snapshot);
9486
- const outline = outlineSummary(snapshot.outline, archived.length);
11512
+ const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
9487
11513
  const counts = worldbookCounts(snapshot.worldbook);
9488
11514
  const scores = archived.slice(-10).map((chapter) => chapter.score);
9489
11515
  const blocking = [...this.effectiveBlocking(snapshot), ...journalBlocking];
@@ -9496,10 +11522,15 @@ var EngineService = class {
9496
11522
  target,
9497
11523
  outline: {
9498
11524
  present: snapshot.outlinePresent && outline.present,
11525
+ target: outline.target,
9499
11526
  total: outline.total,
9500
11527
  planned: outline.planned,
9501
11528
  archived: outline.archived,
11529
+ missing_cards: outline.missingCards,
11530
+ planned_coverage: outline.plannedCoverage,
11531
+ archived_coverage: outline.archivedCoverage,
9502
11532
  coverage: outline.coverage,
11533
+ card_coverage: outline.cardCoverage,
9503
11534
  volumes: snapshot.outline.volumes.map((volume) => {
9504
11535
  const cards = snapshot.outline.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
9505
11536
  return {
@@ -9518,7 +11549,8 @@ var EngineService = class {
9518
11549
  factions: counts.factions,
9519
11550
  rules: counts.rules,
9520
11551
  glossary: counts.glossary,
9521
- backstory: counts.backstory
11552
+ backstory: counts.backstory,
11553
+ facts: counts.facts
9522
11554
  }
9523
11555
  };
9524
11556
  const qualityScores = scores.slice(-5);
@@ -9609,6 +11641,16 @@ var EngineService = class {
9609
11641
  to_stage: target
9610
11642
  };
9611
11643
  }
11644
+ const draft = snapshot.pipeline.draft;
11645
+ if (draft !== void 0 && (draft.chapter !== snapshot.pipeline.chapter || draft.content_hash !== sha256(text))) return {
11646
+ item: {
11647
+ code: "DRAFT_STALE",
11648
+ scope: "archive",
11649
+ detail: "the manuscript or active chapter no longer matches its draft summary",
11650
+ hint: "call novel_rollback to P3 and submit novel_write_draft again"
11651
+ },
11652
+ to_stage: "P3"
11653
+ };
9612
11654
  const summary = snapshot.plan?.summary ?? null;
9613
11655
  const blockers = this.machine(snapshot, text, { summary }).violations.filter((violation) => violation.severity === "blocker");
9614
11656
  if (blockers.length > 0) return {
@@ -10030,8 +12072,31 @@ var EngineService = class {
10030
12072
  }));
10031
12073
  }
10032
12074
  /** Whether the stored brief still signs the current snapshot. */
12075
+ async readBriefReceipts(store) {
12076
+ const raw = await store.readText(BRIEF_RECEIPTS_FILE);
12077
+ if (raw === null) return {
12078
+ schema_version: 1,
12079
+ receipts: []
12080
+ };
12081
+ try {
12082
+ const parsed = parse(raw);
12083
+ if (parsed === null || !Array.isArray(parsed.receipts)) return {
12084
+ schema_version: 1,
12085
+ receipts: []
12086
+ };
12087
+ return {
12088
+ schema_version: 1,
12089
+ receipts: parsed.receipts.filter((receipt) => typeof receipt === "object" && receipt !== null && Number.isInteger(receipt.chapter) && typeof receipt.stage === "string" && typeof receipt.digest === "string" && typeof receipt.issued_at === "string")
12090
+ };
12091
+ } catch {
12092
+ return {
12093
+ schema_version: 1,
12094
+ receipts: []
12095
+ };
12096
+ }
12097
+ }
10033
12098
  briefIsCurrent(snapshot, stage) {
10034
- const record = snapshot.pipeline.briefs[stage];
12099
+ const record = snapshot.briefs[stage];
10035
12100
  if (record === void 0) return false;
10036
12101
  const context = this.briefContextFor(stage, snapshot, snapshot.pipeline.chapter);
10037
12102
  return record.digest === this.briefDigest(stage, snapshot, context);
@@ -10117,6 +12182,8 @@ var EngineService = class {
10117
12182
  genre: snapshot.project.genre,
10118
12183
  sceneType: extra.sceneType ?? snapshot.plan?.scene_type ?? "日常过渡章",
10119
12184
  rulepack: this.rulepack,
12185
+ targets: snapshot.project.targets,
12186
+ facts: snapshot.worldbook.facts ?? [],
10120
12187
  summary: extra.summary ?? snapshot.plan?.summary ?? null,
10121
12188
  evidence: {
10122
12189
  characters: snapshot.characters.characters.length > 0 ? snapshot.characters : null,
@@ -10222,13 +12289,16 @@ var EngineService = class {
10222
12289
  text,
10223
12290
  chapter: snapshot.pipeline.chapter,
10224
12291
  project: snapshot.project,
10225
- pipeline: snapshot.pipeline,
12292
+ pipeline: {
12293
+ ...snapshot.pipeline,
12294
+ briefs: snapshot.briefs
12295
+ },
10226
12296
  chapters: snapshot.chapters,
10227
12297
  briefDigest: extras.briefDigest ?? (extras.briefStage === void 0 || extras.briefStage === null ? null : this.briefDigest(extras.briefStage, snapshot, this.briefContextFor(extras.briefStage, snapshot, snapshot.pipeline.chapter))),
10228
12298
  briefStage: extras.briefStage ?? briefStageFor(operation, snapshot.pipeline.stage),
10229
- draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
10230
- sceneType: extras.sceneType ?? snapshot.plan?.scene_type ?? null,
10231
- summary: extras.summary ?? snapshot.plan?.summary ?? null,
12299
+ draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.pipeline.draft?.title ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
12300
+ sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
12301
+ summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
10232
12302
  summaryProvided: extras.summaryProvided,
10233
12303
  review: extras.review ?? snapshot.review,
10234
12304
  check: extras.check ?? snapshot.check,
@@ -10282,22 +12352,6 @@ var EngineService = class {
10282
12352
  this.assertChapterParameter(chapter);
10283
12353
  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");
10284
12354
  }
10285
- /** True when anything survives in the project directory besides novel.yaml. */
10286
- async projectStateExists(store) {
10287
- const stateFiles = [
10288
- STATE_FILES.pipeline,
10289
- STATE_FILES.chapters,
10290
- STATE_FILES.characters,
10291
- STATE_FILES.world,
10292
- STATE_FILES.locks,
10293
- STATE_FILES.timeline,
10294
- STATE_FILES.foreshadows,
10295
- STATE_FILES.relationships,
10296
- STATE_FILES.handoff
10297
- ].map((file) => joinRel(STATE_DIR, file));
10298
- stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS);
10299
- return (await Promise.all(stateFiles.map((path) => store.exists(path)))).some(Boolean) || (await store.list("chapters")).length > 0 || (await store.list("plans")).length > 0 || (await store.list("reviews")).length > 0 || (await store.list("checks")).length > 0 || await store.exists("outline/outline.yaml") || await store.exists("outline/worldbook.yaml");
10300
- }
10301
12355
  /** A live writer lock makes a read-only snapshot potentially torn. */
10302
12356
  async lockIsLive(store) {
10303
12357
  const text = await store.readText(LOCK_FILE);
@@ -10308,8 +12362,34 @@ var EngineService = class {
10308
12362
  const info = await store.stat(LOCK_FILE);
10309
12363
  return info !== null && Date.now() - info.mtimeMs < 1e3;
10310
12364
  }
10311
- async load(root, retries = 1, allowLock = false) {
10312
- const store = this.storeFor(await this.resolveRoot(root));
12365
+ /** Resolve a selection, load it, then persist the explicit choice (best effort). */
12366
+ async load(root) {
12367
+ const selection = await this.resolveRoot(root);
12368
+ const snapshot = await this.loadAt(selection);
12369
+ await this.recordSelectionBestEffort(selection, snapshot);
12370
+ return snapshot;
12371
+ }
12372
+ /** Load exactly one already-resolved selection; never writes a binding. */
12373
+ async movedProjectHint(selection) {
12374
+ if (selection.created_at === void 0 || selection.created_at === "") return void 0;
12375
+ try {
12376
+ return (await this.registry().read()).projects.find((entry) => entry.root !== selection.root && entry.created_at !== void 0 && entry.created_at === selection.created_at)?.root;
12377
+ } catch {
12378
+ return;
12379
+ }
12380
+ }
12381
+ async loadAt(selection, retries = 1, allowLock = false) {
12382
+ const store = this.storeFor(selection.root);
12383
+ if (selection.source === "session") {
12384
+ const inspection = await inspectProject(selection.root);
12385
+ if (inspection.state === "missing") throw missingProjectError({
12386
+ selection,
12387
+ lastTitle: selection.title,
12388
+ movedTo: await this.movedProjectHint(selection)
12389
+ });
12390
+ if (inspection.state === "not-project") throw projectNotFoundError(selection.root);
12391
+ if (inspection.state === "unreadable") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `the project bound to this session is unreadable: ${inspection.error ?? selection.root}`, "fix the directory permissions, or explicitly rebind with /novel use after the project is readable");
12392
+ }
10313
12393
  if (!allowLock && await this.lockIsLive(store)) {
10314
12394
  for (let attempt = 0; attempt < 10; attempt += 1) {
10315
12395
  await new Promise((resolve) => setTimeout(resolve, 25));
@@ -10318,10 +12398,17 @@ var EngineService = class {
10318
12398
  if (await this.lockIsLive(store)) throw new EngineFaultError(ERROR_CODES.TXN_IN_PROGRESS, "a writer is changing this project; the read-only snapshot would be torn", "retry after the write finishes; do not interpret this snapshot as stable");
10319
12399
  }
10320
12400
  const projectExists = await store.exists(PROJECT_FILE);
10321
- if (!projectExists && !await this.projectStateExists(store)) throw new EngineInputError(ERROR_CODES.PROJECT_NOT_FOUND, `no novel project at ${store.root}`, "call novel_init to create one, or pass the correct project root");
12401
+ if (!projectExists && !await hasProjectState(store)) {
12402
+ if (selection.source === "session") throw missingProjectError({
12403
+ selection,
12404
+ lastTitle: selection.title
12405
+ });
12406
+ throw projectNotFoundError(store.root);
12407
+ }
10322
12408
  let project = {
10323
12409
  ...ENGINE_PROJECT_INITIAL,
10324
- title: "(项目文件不可读)"
12410
+ title: "(项目文件不可读)",
12411
+ created_at: ""
10325
12412
  };
10326
12413
  let projectError = projectExists ? null : `${PROJECT_FILE} is missing`;
10327
12414
  if (projectExists) try {
@@ -10332,8 +12419,28 @@ var EngineService = class {
10332
12419
  if (error instanceof EngineFaultError && (error.code === ERROR_CODES.SCHEMA_TOO_NEW || error.code === ERROR_CODES.PATH_ESCAPE)) throw error;
10333
12420
  projectError = errorMessage(error);
10334
12421
  }
12422
+ if (projectError === null && selection.created_at !== void 0 && selection.created_at !== "") {
12423
+ if (project.created_at === "" || project.created_at !== selection.created_at) throw identityMismatchError({
12424
+ selection,
12425
+ actualCreatedAt: project.created_at,
12426
+ actualTitle: project.title
12427
+ });
12428
+ }
10335
12429
  const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
10336
12430
  if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
12431
+ let briefs = pipeline.briefs;
12432
+ const briefsRaw = await store.readText(BRIEF_RECEIPTS_FILE);
12433
+ if (briefsRaw !== null) try {
12434
+ const parsed = parse(briefsRaw);
12435
+ if (parsed !== null && Array.isArray(parsed.receipts)) {
12436
+ const next = {};
12437
+ for (const receipt of parsed.receipts) if (typeof receipt === "object" && receipt !== null && Number.isInteger(receipt.chapter) && typeof receipt.stage === "string" && typeof receipt.digest === "string" && typeof receipt.issued_at === "string") next[receipt.stage] = {
12438
+ digest: receipt.digest,
12439
+ issued_at: receipt.issued_at
12440
+ };
12441
+ briefs = next;
12442
+ }
12443
+ } catch {}
10337
12444
  const ledgerErrors = [];
10338
12445
  const ledger = async (path, kind, code, fallback) => {
10339
12446
  const read = await this.recoverableDoc(store, path, kind);
@@ -10422,6 +12529,10 @@ var EngineService = class {
10422
12529
  const parsed = parsePlanYaml(planRaw, planChapterFile(chapter));
10423
12530
  validateStoredChapterPlan(parsed, planChapterFile(chapter));
10424
12531
  plan = parsed;
12532
+ if (pipeline.draft !== void 0 && pipeline.draft.chapter === chapter) plan = {
12533
+ ...plan,
12534
+ summary: pipeline.draft.summary
12535
+ };
10425
12536
  } catch (error) {
10426
12537
  if (error instanceof EngineFaultError && error.code === ERROR_CODES.SCHEMA_TOO_NEW) throw error;
10427
12538
  planError = errorMessage(error);
@@ -10451,19 +12562,20 @@ var EngineService = class {
10451
12562
  if (check === null && checkError === null && pipeline.stage === "ready_to_archive") checkError = `${checkFile(chapter)} is missing`;
10452
12563
  const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
10453
12564
  if (latest === null || latest.revision !== pipeline.revision) {
10454
- if (retries > 0) return this.load(store.root, retries - 1, allowLock);
12565
+ if (retries > 0) return this.loadAt(selection, retries - 1, allowLock);
10455
12566
  throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the project changed while its snapshot was being read", "retry the read; if it keeps moving, wait for the writer to finish");
10456
12567
  }
10457
12568
  if (!allowLock && await this.lockIsLive(store)) {
10458
- if (retries > 0) return this.load(store.root, retries - 1, false);
12569
+ if (retries > 0) return this.loadAt(selection, retries - 1, false);
10459
12570
  throw new EngineFaultError(ERROR_CODES.TXN_IN_PROGRESS, "a writer started while the snapshot was being read; the snapshot is torn", "retry after the write finishes; do not interpret this snapshot as stable");
10460
12571
  }
10461
- this.lastRoot = store.root;
10462
12572
  return {
10463
12573
  root: store.root,
12574
+ selection,
10464
12575
  project,
10465
12576
  projectError,
10466
12577
  pipeline,
12578
+ briefs,
10467
12579
  chapters,
10468
12580
  characters,
10469
12581
  world,
@@ -10524,7 +12636,8 @@ var EngineService = class {
10524
12636
  }
10525
12637
  }
10526
12638
  async mutate(options) {
10527
- const root = await this.resolveRoot(options.root);
12639
+ const selection = options.selection ?? await this.resolveRoot(options.root);
12640
+ const root = selection.root;
10528
12641
  const signal = this.signalContext.getStore();
10529
12642
  const abortIfNeeded = () => {
10530
12643
  if (signal?.aborted === true) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `${options.op} was aborted before commit`, "retry the call if you still want the write");
@@ -10533,9 +12646,24 @@ var EngineService = class {
10533
12646
  return this.mutex.run(root, async () => {
10534
12647
  if (options.emptyAllowed === true) ensureDirAt(root);
10535
12648
  const store = this.storeFor(root);
12649
+ if (options.emptyAllowed !== true && selection.source === "session") {
12650
+ const inspection = await inspectProject(selection.root);
12651
+ if (inspection.state === "missing") throw missingProjectError({
12652
+ selection,
12653
+ lastTitle: selection.title,
12654
+ movedTo: await this.movedProjectHint(selection)
12655
+ });
12656
+ if (inspection.state === "not-project") throw projectNotFoundError(selection.root);
12657
+ if (inspection.state === "unreadable") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `the project bound to this session is unreadable: ${inspection.error ?? selection.root}`, "fix the directory permissions, or explicitly rebind with /novel use after the project is readable");
12658
+ if (inspection.state === "damaged" && selection.created_at !== void 0 && selection.created_at !== "") throw identityMismatchError({
12659
+ selection,
12660
+ actualTitle: inspection.title
12661
+ });
12662
+ }
10536
12663
  const journalPath = joinRel(STATE_DIR, STATE_FILES.journal);
10537
12664
  const journalWasPresent = await store.exists(journalPath);
10538
12665
  const projectExists = await store.exists(PROJECT_FILE);
12666
+ if (options.emptyAllowed !== true && !projectExists && !await hasProjectState(store)) throw projectNotFoundError(root);
10539
12667
  if (options.emptyAllowed === true && projectExists) throw new EngineInputError(ERROR_CODES.PROJECT_EXISTS, `a project already exists at ${store.root}`, "use a different directory, or call novel_status to inspect the existing project");
10540
12668
  if (options.emptyAllowed !== true && projectExists && !await store.exists(joinRel("state", STATE_FILES.journal))) throw new EngineFaultError(ERROR_CODES.JOURNAL_MISSING, "state/journal.jsonl is missing; journal integrity and crash recovery are unavailable", "restore state/journal.jsonl from a backup before writing; a project with novel.yaml must have its audit journal");
10541
12669
  const lock = new FileLock(join(root, LOCK_FILE), void 0, void 0, async (record) => {
@@ -10565,11 +12693,11 @@ var EngineService = class {
10565
12693
  STATE_FILES.relationships,
10566
12694
  STATE_FILES.handoff
10567
12695
  ].map((file) => joinRel(STATE_DIR, file));
10568
- stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS);
12696
+ stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
10569
12697
  const stateCandidates = journalWasPresent ? stateFiles : stateFiles.filter((path) => path !== joinRel(STATE_DIR, STATE_FILES.journal));
10570
12698
  if ((await Promise.all(stateCandidates.map((file) => store.exists(file)))).some(Boolean) || (await store.list("chapters")).length > 0 || (await store.list("plans")).length > 0 || (await store.list("reviews")).length > 0 || (await store.list("checks")).length > 0 || await store.exists("outline/outline.yaml") || await store.exists("outline/worldbook.yaml")) throw new EngineInputError(ERROR_CODES.PROJECT_EXISTS, "state documents exist but novel.yaml is missing; refusing to overwrite the project", "restore novel.yaml, or initialize a new empty directory");
10571
12699
  await assertJournalConsistent(store);
10572
- const created = await options.run(this.emptySnapshot(root), {
12700
+ const created = await options.run(this.emptySnapshot(root, selection), {
10573
12701
  store,
10574
12702
  revision: 1
10575
12703
  });
@@ -10584,7 +12712,6 @@ var EngineService = class {
10584
12712
  ...options.reason === void 0 ? {} : { reason: options.reason }
10585
12713
  });
10586
12714
  this.replay.clear();
10587
- this.lastRoot = root;
10588
12715
  return {
10589
12716
  data: created.data,
10590
12717
  revision: 1,
@@ -10593,7 +12720,11 @@ var EngineService = class {
10593
12720
  }
10594
12721
  }
10595
12722
  await assertJournalConsistent(store, options.repairPaths ?? []);
10596
- const snapshot = await this.load(root, 1, true);
12723
+ const snapshot = await this.loadAt(selection, 1, true);
12724
+ if (selection.source === "session" && selection.created_at !== void 0 && selection.created_at !== "" && snapshot.projectError !== null) throw identityMismatchError({
12725
+ selection,
12726
+ actualTitle: snapshot.project.title
12727
+ });
10597
12728
  const key = options.key?.(snapshot);
10598
12729
  if (key !== void 0) {
10599
12730
  const cached = this.replay.get(`${root}|${options.op}|${key}`);
@@ -10603,12 +12734,32 @@ var EngineService = class {
10603
12734
  replayed: true
10604
12735
  };
10605
12736
  }
10606
- const revision = snapshot.pipeline.revision + 1;
12737
+ const revision = options.advanceRevision === false ? snapshot.pipeline.revision : snapshot.pipeline.revision + 1;
10607
12738
  abortIfNeeded();
10608
- const outcome = await options.run(snapshot, {
10609
- store,
10610
- revision
10611
- });
12739
+ let outcome;
12740
+ try {
12741
+ outcome = await options.run(snapshot, {
12742
+ store,
12743
+ revision
12744
+ });
12745
+ } catch (error) {
12746
+ await appendJournal(store, {
12747
+ txid: randomUUID(),
12748
+ at: this.now().toISOString(),
12749
+ op: options.op,
12750
+ chapter: options.chapter ?? snapshot.pipeline.chapter,
12751
+ revision: snapshot.pipeline.revision,
12752
+ files: [],
12753
+ status: "aborted",
12754
+ reason: errorMessage(error),
12755
+ attempt: true,
12756
+ stage: snapshot.pipeline.stage,
12757
+ ...error instanceof EngineError ? { code: error.code } : {},
12758
+ ...error instanceof EngineError && error.hint !== void 0 ? { hint: error.hint } : {},
12759
+ ...error instanceof EngineBlockedError ? { blocking: error.blocking } : {}
12760
+ }).catch(() => void 0);
12761
+ throw error;
12762
+ }
10612
12763
  if (outcome.replay === true) return {
10613
12764
  data: outcome.data,
10614
12765
  revision: snapshot.pipeline.revision,
@@ -10619,7 +12770,7 @@ var EngineService = class {
10619
12770
  await runTransaction(store, {
10620
12771
  txid: randomUUID(),
10621
12772
  op: options.op,
10622
- chapter: snapshot.pipeline.chapter,
12773
+ chapter: options.chapter ?? snapshot.pipeline.chapter,
10623
12774
  revision,
10624
12775
  files: outcome.files,
10625
12776
  backup: outcome.backup ?? false,
@@ -10632,6 +12783,21 @@ var EngineService = class {
10632
12783
  revision,
10633
12784
  warnings: outcome.warnings ?? []
10634
12785
  };
12786
+ let metadataSnapshot = snapshot;
12787
+ if ((options.bindOnSuccess !== false || options.registerOnSuccess !== false) && outcome.files.some((file) => file.path === "novel.yaml")) try {
12788
+ metadataSnapshot = await this.loadAt(selection, 1, true);
12789
+ } catch {}
12790
+ try {
12791
+ await this.recordSelection(selection, metadataSnapshot, {
12792
+ bind: options.bindOnSuccess,
12793
+ register: options.registerOnSuccess
12794
+ });
12795
+ } catch (error) {
12796
+ result.warnings.push({
12797
+ code: "SESSION_BINDING_FAILED",
12798
+ message: `project write committed, but project selection metadata could not be written: ${errorMessage(error)}`
12799
+ });
12800
+ }
10635
12801
  if (key !== void 0) {
10636
12802
  this.invalidateReplay(root, options.op);
10637
12803
  this.replay.set(`${root}|${options.op}|${key}`, result);
@@ -10651,9 +12817,10 @@ var EngineService = class {
10651
12817
  if (contentChanging || !key.includes("|export|")) this.replay.delete(key);
10652
12818
  }
10653
12819
  }
10654
- emptySnapshot(root) {
12820
+ emptySnapshot(root, selection) {
10655
12821
  return {
10656
12822
  root,
12823
+ selection,
10657
12824
  project: { ...ENGINE_PROJECT_INITIAL },
10658
12825
  projectError: null,
10659
12826
  pipeline: emptyPipeline({
@@ -10661,6 +12828,7 @@ var EngineService = class {
10661
12828
  chapter: 1,
10662
12829
  stage: "idle"
10663
12830
  }),
12831
+ briefs: {},
10664
12832
  chapters: emptyChapters(),
10665
12833
  characters: emptyCharacters(),
10666
12834
  world: emptyWorld(),
@@ -10728,6 +12896,38 @@ const STATUS_BRIEF = {
10728
12896
  done: "P1"
10729
12897
  };
10730
12898
  const yaml = (value) => stringify(value, { lineWidth: 0 });
12899
+ /** Apply a patch only to the exact manuscript the caller hashed. */
12900
+ function applyRevisionPatch(text, patch) {
12901
+ const actual = sha256(text);
12902
+ if (actual !== patch.expected_hash) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch expected_hash ${patch.expected_hash} does not match the current manuscript ${actual}`, "read the current text and compute its sha256 again; nothing was written");
12903
+ const planned = [];
12904
+ for (const [index, edit] of patch.edits.entries()) {
12905
+ const positions = [];
12906
+ let cursor = 0;
12907
+ while (cursor <= text.length) {
12908
+ const found = text.indexOf(edit.old_text, cursor);
12909
+ if (found === -1) break;
12910
+ positions.push(found);
12911
+ cursor = found + 1;
12912
+ }
12913
+ const expectedOccurrence = edit.occurrence ?? 1;
12914
+ if (edit.occurrence === void 0 && positions.length !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}] matches ${positions.length} place(s); a unique old_text is required`, "include more surrounding text, or set occurrence explicitly");
12915
+ if (positions.length < expectedOccurrence) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}].occurrence ${expectedOccurrence} exceeds ${positions.length} match(es)`);
12916
+ const start = positions[expectedOccurrence - 1];
12917
+ const current = {
12918
+ index,
12919
+ start,
12920
+ end: start + edit.old_text.length,
12921
+ newText: edit.new_text
12922
+ };
12923
+ if (planned.some((other) => current.start < other.end && other.start < current.end)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `patch.edits[${index}] overlaps another edit; overlapping edits cannot be applied atomically`, "merge overlapping edits into one old_text/new_text pair; nothing was written");
12924
+ planned.push(current);
12925
+ }
12926
+ planned.sort((left, right) => right.start - left.start);
12927
+ let next = text;
12928
+ for (const edit of planned) next = `${next.slice(0, edit.start)}${edit.newText}${next.slice(edit.end)}`;
12929
+ return next;
12930
+ }
10731
12931
  const emptyChapters = () => ({
10732
12932
  schema_version: 1,
10733
12933
  chapters: []
@@ -10752,6 +12952,7 @@ const pipelineWith = (snapshot, revision, patch) => ({
10752
12952
  rounds: snapshot.pipeline.rounds,
10753
12953
  briefs: snapshot.pipeline.briefs,
10754
12954
  blocking: snapshot.pipeline.blocking,
12955
+ ...snapshot.pipeline.draft === void 0 ? {} : { draft: snapshot.pipeline.draft },
10755
12956
  review_digest: snapshot.pipeline.review_digest,
10756
12957
  last_action: snapshot.pipeline.last_action,
10757
12958
  ...patch
@@ -10775,7 +12976,7 @@ const action = (op, at, digest) => ({
10775
12976
  at: at.toISOString(),
10776
12977
  digest
10777
12978
  });
10778
- const machineWarnings = (machine) => machine.violations.filter((violation) => violation.severity === "advisory").slice(0, 5).map((violation) => ({
12979
+ const machineWarnings = (machine, acknowledged = []) => machine.violations.filter((violation) => violation.severity === "advisory" && !acknowledged.includes(violation.code)).slice(0, 5).map((violation) => ({
10779
12980
  code: violation.code,
10780
12981
  message: `${violation.detail} (paragraph ${violation.location.paragraph})`
10781
12982
  }));
@@ -10940,7 +13141,10 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
10940
13141
  "narrator",
10941
13142
  "score",
10942
13143
  "status",
10943
- "summary"
13144
+ "summary",
13145
+ "rounds",
13146
+ "final_check",
13147
+ "archived_at"
10944
13148
  ]) if (stableJson(before[field]) !== stableJson(after[field])) problems.push(`archived chapter ${String(before.num)} cannot change its ${field}`);
10945
13149
  }
10946
13150
  return problems;
@@ -11354,14 +13558,22 @@ const fail = (error) => {
11354
13558
  }
11355
13559
  throw error;
11356
13560
  };
11357
- /** Wrap an execute body with cancellation and a machine-code error. */
11358
- const makeGuarded = (engine) => (body) => async (args, exec) => {
13561
+ /** Wrap an execute body with caller context, cancellation and a machine-code error. */
13562
+ const makeGuarded = (engine, workingDirectory) => (body) => async (args, exec) => {
13563
+ const caller = {};
13564
+ if (typeof exec?.agent?.id === "string" && exec.agent.id !== "") caller.sessionId = exec.agent.id;
13565
+ if (exec?.agent !== void 0 && workingDirectory !== void 0) try {
13566
+ caller.cwd = await workingDirectory.ensure(exec.agent, exec.signal);
13567
+ } catch {}
11359
13568
  try {
11360
- return await engine.withSignal(exec?.signal, () => body(args));
13569
+ const invoke = () => engine.withSignal(exec?.signal, () => body(args));
13570
+ const withCaller = engine.withCaller;
13571
+ return await (typeof withCaller === "function" ? withCaller.call(engine, caller, invoke) : invoke());
11361
13572
  } catch (error) {
11362
13573
  return fail(error);
11363
13574
  }
11364
13575
  };
13576
+ const percent = (value) => `${Math.round(value * 1e3) / 10}%`;
11365
13577
  const text = (value) => [{
11366
13578
  type: "text",
11367
13579
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -11405,14 +13617,22 @@ const stringArray = {
11405
13617
  };
11406
13618
  const root = {
11407
13619
  type: "string",
11408
- 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."
13620
+ 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."
13621
+ };
13622
+ const initRoot = {
13623
+ type: "string",
13624
+ description: "Project root for the new project. Passing it creates the project there. When omitted, only the configured projectRoot is used; novel_init does not follow the session binding, workspace discovery or registry active. The created project is bound to the current session on success."
13625
+ };
13626
+ const utilityRoot = {
13627
+ type: "string",
13628
+ description: "Project root for the project-backed mode of this tool. Text-only word_count/diff calls ignore it and do not create a session binding; use a project-backed call when you want to select a book."
11409
13629
  };
11410
13630
  /**
11411
- * The twenty-seven tools are thin adapters: narrow parameters, call one service
13631
+ * The twenty-eight tools are thin adapters: narrow parameters, call one service
11412
13632
  * operation, render the canonical value. They never read a file.
11413
13633
  */
11414
- function createNovelTools(engine) {
11415
- const guarded = makeGuarded(engine);
13634
+ function createNovelTools(engine, options = {}) {
13635
+ const guarded = makeGuarded(engine, options.workingDirectory);
11416
13636
  return [
11417
13637
  defineTool({
11418
13638
  name: "novel_init",
@@ -11451,7 +13671,7 @@ function createNovelTools(engine) {
11451
13671
  selling_points: stringArray,
11452
13672
  ending: { type: "string" },
11453
13673
  strict_outline: { type: "boolean" },
11454
- project_root: root
13674
+ project_root: initRoot
11455
13675
  },
11456
13676
  output: jsonOutput,
11457
13677
  execute: guarded(async (args) => engine.initProject({
@@ -11489,10 +13709,12 @@ function createNovelTools(engine) {
11489
13709
  const where = active === value.chapter ? `第 ${value.chapter} 章` : `当前第 ${active} 章 · 查询第 ${value.chapter} 章字数`;
11490
13710
  return text([
11491
13711
  `《${value.project.title}》 ${where} · ${value.stage}`,
13712
+ ...value.selection === void 0 ? [] : [`项目根 ${value.selection.root} · 选择来源 ${value.selection.source}`],
11492
13713
  ...value.rounds === void 0 ? [] : [`轮次:评审 ${value.rounds.review} / 修订 ${value.rounds.revise} / 校验 ${value.rounds.check}`],
11493
13714
  value.blocking.length === 0 ? "门控:全部通过" : `门控:${value.blocking.length} 条阻塞`,
11494
13715
  ...value.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
11495
- ...value.book === void 0 ? [] : [`全书:大纲 ${value.book.outline.total} 张卡(已归档 ${value.book.outline.archived},覆盖率 ${Math.round(value.book.outline.coverage * 100)}%)· 世界观 ${value.book.worldbook.characters} 角色 / ${value.book.worldbook.locations} 地点 / ${value.book.worldbook.factions} 势力 / ${value.book.worldbook.rules} 规则${value.book.strict ? " · 严格大纲" : ""}`, `当前章卡:${value.book.outline.current === null ? "(无)" : `第${value.book.outline.current.num}章 ${value.book.outline.current.title} · ${value.book.outline.current.scene_type} · ${value.book.outline.current.target_words[0]}-${value.book.outline.current.target_words[1]} 字`}`],
13716
+ ...value.metrics === null || value.metrics === void 0 ? [] : [`字数:${value.metrics.total_word_count}(汉字 ${value.metrics.cjk_chars} + 标点 ${value.metrics.punctuation_chars} + 英文词/数字,含标点)`],
13717
+ ...value.book === void 0 ? [] : [`全书:大纲 ${value.book.outline.total} 张卡(规划覆盖 ${value.book.outline.planned_coverage === null || value.book.outline.planned_coverage === void 0 ? "—" : percent(value.book.outline.planned_coverage)} · 归档覆盖 ${value.book.outline.archived_coverage === null || value.book.outline.archived_coverage === void 0 ? "—" : percent(value.book.outline.archived_coverage)}${(value.book.outline.missing_cards ?? 0) > 0 ? ` · 缺卡 ${value.book.outline.missing_cards}` : ""})· 世界观 ${value.book.worldbook.characters} 角色 / ${value.book.worldbook.locations} 地点 / ${value.book.worldbook.factions} 势力 / ${value.book.worldbook.rules} 规则${value.book.strict ? " · 严格大纲" : ""}`, `当前章卡:${value.book.outline.current === null ? "(无)" : `第${value.book.outline.current.num}章 ${value.book.outline.current.title} · ${value.book.outline.current.scene_type} · ${value.book.outline.current.target_words[0]}-${value.book.outline.current.target_words[1]} 字`}`],
11496
13718
  `下一步:${value.next.tool} — ${value.next.reason}`
11497
13719
  ].join("\n"));
11498
13720
  }
@@ -11701,7 +13923,11 @@ function createNovelTools(engine) {
11701
13923
  },
11702
13924
  content: {
11703
13925
  type: "string",
11704
- required: true
13926
+ description: "Full revised text; mutually exclusive with patch."
13927
+ },
13928
+ patch: {
13929
+ type: "json",
13930
+ description: "Patch mode: {expected_hash, edits:[{old_text,new_text,occurrence?}]}; edits must be unique unless occurrence is set."
11705
13931
  },
11706
13932
  summary: { type: "json" },
11707
13933
  issues: {
@@ -11718,7 +13944,8 @@ function createNovelTools(engine) {
11718
13944
  output: jsonOutput,
11719
13945
  execute: guarded(async (args) => engine.submitRevision({
11720
13946
  chapter: args.chapter,
11721
- content: args.content,
13947
+ ...args.content === void 0 ? {} : { content: args.content },
13948
+ ...args.patch === void 0 ? {} : { patch: asJson(args.patch, "patch") },
11722
13949
  summary: asJson(args.summary, "summary"),
11723
13950
  issues: asJson(args.issues, "issues"),
11724
13951
  proof: asJson(args.proof, "proof"),
@@ -11739,6 +13966,11 @@ function createNovelTools(engine) {
11739
13966
  required: true,
11740
13967
  description: "Exactly the five P6 semantic items, each with a verbatim evidence quote."
11741
13968
  },
13969
+ acknowledge: {
13970
+ type: "array",
13971
+ items: { type: "string" },
13972
+ description: "Advisory codes read and accepted; blockers cannot be acknowledged."
13973
+ },
11742
13974
  project_root: root
11743
13975
  },
11744
13976
  output: jsonOutput,
@@ -11746,12 +13978,13 @@ function createNovelTools(engine) {
11746
13978
  chapter: args.chapter,
11747
13979
  scope: args.scope,
11748
13980
  semantic: asJson(args.semantic, "semantic"),
13981
+ ...args.acknowledge === void 0 ? {} : { acknowledge: args.acknowledge },
11749
13982
  root: args.project_root
11750
13983
  }))
11751
13984
  }),
11752
13985
  defineTool({
11753
13986
  name: "novel_archive",
11754
- description: "Archive the active chapter: all gates run, then one transaction writes the index, five ledgers and handoff.",
13987
+ description: "Archive the active chapter: all gates run, then one transaction writes the index, eight ledgers and handoff.",
11755
13988
  parameters: {
11756
13989
  chapter: {
11757
13990
  type: "integer",
@@ -11788,7 +14021,7 @@ function createNovelTools(engine) {
11788
14021
  parameters: {
11789
14022
  text: { type: "string" },
11790
14023
  chapter: { type: "integer" },
11791
- project_root: root
14024
+ project_root: utilityRoot
11792
14025
  },
11793
14026
  output: jsonOutput,
11794
14027
  execute: guarded(async (args) => engine.countWords({
@@ -11799,7 +14032,7 @@ function createNovelTools(engine) {
11799
14032
  }),
11800
14033
  defineTool({
11801
14034
  name: "novel_state",
11802
- description: "Read a state document (ledgers, plans, review, check, outline, worldbook) without touching the manuscript.",
14035
+ description: "Read a state document (ledgers, plans, review, check, outline, worldbook), list brief receipts, revision history or backup manifests without touching the manuscript.",
11803
14036
  parameters: {
11804
14037
  kind: {
11805
14038
  type: "string",
@@ -11820,10 +14053,14 @@ function createNovelTools(engine) {
11820
14053
  "planWorld",
11821
14054
  "planChapter",
11822
14055
  "outline",
11823
- "worldbook"
14056
+ "worldbook",
14057
+ "history",
14058
+ "backups",
14059
+ "briefs"
11824
14060
  ]
11825
14061
  },
11826
14062
  chapter: { type: "integer" },
14063
+ revision: { type: "integer" },
11827
14064
  include_summary: {
11828
14065
  type: "boolean",
11829
14066
  description: "For kind=chapters: include the full ten-field summary (default compact)."
@@ -11839,6 +14076,7 @@ function createNovelTools(engine) {
11839
14076
  execute: guarded(async (args) => engine.queryState({
11840
14077
  kind: args.kind,
11841
14078
  chapter: args.chapter,
14079
+ ...args.revision === void 0 ? {} : { revision: args.revision },
11842
14080
  ...args.include_summary === void 0 ? {} : { include_summary: args.include_summary },
11843
14081
  ...args.chapter_range === void 0 ? {} : { chapter_range: args.chapter_range },
11844
14082
  root: args.project_root
@@ -11921,7 +14159,7 @@ function createNovelTools(engine) {
11921
14159
  chapter: { type: "integer" },
11922
14160
  before: { type: "string" },
11923
14161
  after: { type: "string" },
11924
- project_root: root
14162
+ project_root: utilityRoot
11925
14163
  },
11926
14164
  output: jsonOutput,
11927
14165
  execute: guarded(async (args) => engine.diff({
@@ -11959,14 +14197,29 @@ function createNovelTools(engine) {
11959
14197
  }),
11960
14198
  defineTool({
11961
14199
  name: "novel_audit",
11962
- description: "Read the append-only journal as an audit trail of every committed and aborted transaction.",
14200
+ description: "Read the append-only journal, including refused gate attempts, with chapter/op/status/time filters and a revision cursor.",
11963
14201
  parameters: {
11964
14202
  limit: { type: "integer" },
14203
+ cursor: { type: "integer" },
14204
+ chapter: { type: "integer" },
14205
+ op: { type: "string" },
14206
+ status: {
14207
+ type: "string",
14208
+ enum: ["committed", "aborted"]
14209
+ },
14210
+ since: { type: "string" },
14211
+ until: { type: "string" },
11965
14212
  project_root: root
11966
14213
  },
11967
14214
  output: jsonOutput,
11968
14215
  execute: guarded(async (args) => engine.auditHistory({
11969
14216
  limit: args.limit,
14217
+ cursor: args.cursor,
14218
+ chapter: args.chapter,
14219
+ op: args.op,
14220
+ status: args.status,
14221
+ since: args.since,
14222
+ until: args.until,
11970
14223
  root: args.project_root
11971
14224
  }))
11972
14225
  }),
@@ -12083,6 +14336,10 @@ function createNovelTools(engine) {
12083
14336
  characters: { type: "json" },
12084
14337
  glossary: { type: "json" },
12085
14338
  backstory: { type: "json" },
14339
+ facts: {
14340
+ type: "json",
14341
+ description: "Declared numeric canon: [{alias,value,unit,tolerance?,note?}]."
14342
+ },
12086
14343
  themes: stringArray,
12087
14344
  project_root: root
12088
14345
  },
@@ -12096,6 +14353,7 @@ function createNovelTools(engine) {
12096
14353
  characters: asJson(args.characters, "characters"),
12097
14354
  glossary: asJson(args.glossary, "glossary"),
12098
14355
  backstory: asJson(args.backstory, "backstory"),
14356
+ ...args.facts === void 0 ? {} : { facts: asJson(args.facts, "facts") },
12099
14357
  themes: args.themes,
12100
14358
  root: args.project_root
12101
14359
  }))
@@ -12171,6 +14429,30 @@ function createNovelTools(engine) {
12171
14429
  reason: args.reason,
12172
14430
  root: args.project_root
12173
14431
  }))
14432
+ }),
14433
+ defineTool({
14434
+ name: "novel_restore",
14435
+ 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.",
14436
+ parameters: {
14437
+ txid: {
14438
+ type: "string",
14439
+ required: true
14440
+ },
14441
+ chapter: { type: "integer" },
14442
+ paths: {
14443
+ type: "array",
14444
+ items: { type: "string" },
14445
+ description: "Project-relative chapter files; omitted restores every restorable chapter file that the backup manifest records."
14446
+ },
14447
+ project_root: root
14448
+ },
14449
+ output: jsonOutput,
14450
+ execute: guarded(async (args) => engine.restoreBackup({
14451
+ txid: args.txid,
14452
+ chapter: args.chapter,
14453
+ ...args.paths === void 0 ? {} : { paths: args.paths },
14454
+ root: args.project_root
14455
+ }))
12174
14456
  })
12175
14457
  ];
12176
14458
  }
@@ -12214,7 +14496,7 @@ const rootArg = (args) => {
12214
14496
  return args.root;
12215
14497
  };
12216
14498
  /**
12217
- * The seventeen tools hidden when `tools.mode=core`, plus the explicit
14499
+ * The eighteen tools hidden when `tools.mode=core`, plus the explicit
12218
14500
  * registry/settings-pack operations that are command-only by design (§6.8).
12219
14501
  * They stay reachable through `/novel admin <operation> <json>`; the caller
12220
14502
  * supplies the same arguments the tool schema would have accepted.
@@ -12242,9 +14524,23 @@ const OPERATIONS$1 = {
12242
14524
  novel_report: (engine, args) => engine.report(args),
12243
14525
  novel_guide: (engine, args) => engine.guide(args),
12244
14526
  novel_reopen: (engine, args) => engine.reopenChapter(args),
14527
+ novel_restore: (engine, args) => engine.restoreBackup(args),
12245
14528
  novel_registry_list: (engine) => engine.listProjects(),
12246
- novel_registry_register: (engine, args) => engine.registerProject({ root: rootArg(args) }),
12247
- novel_registry_activate: (engine, args) => engine.setActiveProject({ root: rootArg(args) }),
14529
+ novel_registry_register: async (engine, args) => {
14530
+ const result = await engine.registerProject({ root: rootArg(args) });
14531
+ return {
14532
+ ...result,
14533
+ ...result.replaced ? { message: "原路径已换成另一本书;登记行已更新。已有会话绑定仍会按 created_at 拒绝,请重新 /novel use。" } : {}
14534
+ };
14535
+ },
14536
+ novel_registry_activate: async (engine, args) => {
14537
+ const result = await engine.setActiveProject({ root: rootArg(args) });
14538
+ return {
14539
+ ...result,
14540
+ message: "active 只影响面板默认;当前会话没有切换。写作请用 /novel use 或显式 project_root。",
14541
+ ...result.replaced ? { replacement: "原路径已换成另一本书;登记行已更新,旧会话绑定仍需重新 /novel use。" } : {}
14542
+ };
14543
+ },
12248
14544
  novel_registry_forget: (engine, args) => engine.forgetProject({ root: rootArg(args) }),
12249
14545
  novel_import: (engine, args) => engine.importPack(args)
12250
14546
  };
@@ -12305,16 +14601,18 @@ const bad = (text) => ({
12305
14601
  text
12306
14602
  });
12307
14603
  const failure = (error) => error instanceof EngineError ? bad(`[${error.code}] ${error.message}${error.hint === void 0 ? "" : ` — ${error.hint}`}`) : bad(String(error));
14604
+ const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
12308
14605
  const statusText = async (engine) => {
12309
14606
  const { data } = await engine.getStatus({});
12310
14607
  return [
12311
14608
  `《${data.project.title}》 ${data.project.genre} · ${data.project.platform}`,
12312
14609
  `第 ${data.chapter} 章 · 阶段 ${data.stage}`,
14610
+ ...data.selection === void 0 ? [] : [`项目根 ${data.selection.root} · 选择来源 ${data.selection.source}`],
12313
14611
  `轮次 评审 ${data.rounds.review} / 修订 ${data.rounds.revise} / 校验 ${data.rounds.check}`,
12314
- data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}`,
14612
+ data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}(汉字 ${data.metrics.cjk_chars} + 标点 ${data.metrics.punctuation_chars},含标点)`,
12315
14613
  data.blocking.length === 0 ? "门控 全部通过" : `门控 ${data.blocking.length} 条阻塞:`,
12316
14614
  ...data.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
12317
- ...data.book === void 0 ? [] : [`全书:大纲 ${data.book.outline.total} 张卡(已归档 ${data.book.outline.archived},覆盖率 ${Math.round(data.book.outline.coverage * 100)}%)· 世界观 ${data.book.worldbook.characters} 角色 / ${data.book.worldbook.locations} 地点 / ${data.book.worldbook.factions} 势力 / ${data.book.worldbook.rules} 规则${data.book.strict ? " · 严格大纲" : ""}`, ...data.book.outline.current === null ? ["当前章卡:(无)"] : [`当前章卡:第${data.book.outline.current.num}章 ${data.book.outline.current.title} · ${data.book.outline.current.scene_type} · ${data.book.outline.current.target_words[0]}-${data.book.outline.current.target_words[1]} 字`]],
14615
+ ...data.book === void 0 ? [] : [`全书:大纲 ${data.book.outline.total} 张卡(规划覆盖 ${formatPercent(data.book.outline.planned_coverage)} · 归档覆盖 ${formatPercent(data.book.outline.archived_coverage)}${data.book.outline.missing_cards > 0 ? ` · 缺卡 ${data.book.outline.missing_cards}` : ""})· 世界观 ${data.book.worldbook.characters} 角色 / ${data.book.worldbook.locations} 地点 / ${data.book.worldbook.factions} 势力 / ${data.book.worldbook.rules} 规则${data.book.strict ? " · 严格大纲" : ""}`, ...data.book.outline.current === null ? ["当前章卡:(无)"] : [`当前章卡:第${data.book.outline.current.num}章 ${data.book.outline.current.title} · ${data.book.outline.current.scene_type} · ${data.book.outline.current.target_words[0]}-${data.book.outline.current.target_words[1]} 字`]],
12318
14616
  `下一步 ${data.next.tool}:${data.next.reason}`
12319
14617
  ].join("\n");
12320
14618
  };
@@ -12328,6 +14626,12 @@ const usage = () => [
12328
14626
  "/novel doctor 项目体检",
12329
14627
  "/novel search <词> 搜索正文/大纲/世界观/台账",
12330
14628
  "/novel export md|txt|epub|outline|worldbook|proposal",
14629
+ "/novel projects 列出项目与目录状态",
14630
+ "/novel current 当前会话绑定与选择来源",
14631
+ "/novel use <path|title|.> 切换当前会话项目",
14632
+ "/novel unbind 清除当前会话绑定(不删项目)",
14633
+ "/novel scan [--register] 扫描工作区候选",
14634
+ "/novel resume 输出可粘贴的续写指令",
12331
14635
  "/novel admin 隐藏操作与参数说明",
12332
14636
  "/novel admin <op> <json> 执行隐藏操作",
12333
14637
  "/novel-next 等价于 /novel next",
@@ -12335,9 +14639,10 @@ const usage = () => [
12335
14639
  "/novel-export <kind>",
12336
14640
  "/novel-admin [<op> <json>]"
12337
14641
  ].join("\n");
12338
- /** The five slash commands; every one calls a service operation, none touches a file. */
12339
- function createCommands(engine) {
12340
- const dispatch = async (raw, signal) => {
14642
+ /** Every command calls a service operation; none touches a file directly. */
14643
+ function createCommands(engine, host = {}) {
14644
+ const dispatch = async (invocation) => {
14645
+ const { rawInput: raw, signal } = invocation;
12341
14646
  if (signal.aborted) return bad("已取消");
12342
14647
  const [head = "", ...rest] = raw.trim().split(/\s+/).filter((part) => part !== "");
12343
14648
  try {
@@ -12351,7 +14656,7 @@ function createCommands(engine) {
12351
14656
  }
12352
14657
  case "stats": {
12353
14658
  const stats = await engine.stats({});
12354
- return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.plannedChapters} 卡(覆盖 ${Math.round(stats.outlineCoverage * 100)}%)· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
14659
+ return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
12355
14660
  }
12356
14661
  case "outline": {
12357
14662
  const status = await engine.getStatus({});
@@ -12365,7 +14670,7 @@ function createCommands(engine) {
12365
14670
  if (book === void 0) return ok("这本书还没有书级规划数据。");
12366
14671
  return ok([
12367
14672
  `蓝图:${book.premise === "" ? "(空)" : book.premise}`,
12368
- `大纲:${book.outline.total} 张卡 · 已归档 ${book.outline.archived} · 覆盖率 ${Math.round(book.outline.coverage * 100)}% · 严格模式 ${book.strict ? "开" : "关"}`,
14673
+ `大纲:${book.outline.total} 张卡 · 规划覆盖 ${formatPercent(book.outline.planned_coverage)} · 归档覆盖 ${formatPercent(book.outline.archived_coverage)}${book.outline.missing_cards > 0 ? ` · 缺卡 ${book.outline.missing_cards}` : ""} · 严格模式 ${book.strict ? "开" : "关"}`,
12369
14674
  `当前章卡:${book.outline.current === null ? "(无)" : `第${book.outline.current.num}章 ${book.outline.current.title} / ${book.outline.current.scene_type}`}`,
12370
14675
  `世界观:${book.worldbook.characters} 角色 · ${book.worldbook.locations} 地点 · ${book.worldbook.factions} 势力 · ${book.worldbook.rules} 规则`
12371
14676
  ].join("\n"));
@@ -12411,6 +14716,78 @@ function createCommands(engine) {
12411
14716
  const { data } = await engine.export({ kind });
12412
14717
  return ok(`已导出 ${data.kind}:${data.files.join(", ")}`);
12413
14718
  }
14719
+ case "projects": {
14720
+ const list = await engine.listProjects();
14721
+ let current;
14722
+ try {
14723
+ const selection = await engine.currentProject();
14724
+ current = selection.bound ? selection.selection?.root : void 0;
14725
+ } catch {}
14726
+ const lines = [
14727
+ `项目 ${list.projects.length} 个${list.active === null ? "" : ` · 面板默认 ${list.active}`}`,
14728
+ ...list.projects.map((project) => {
14729
+ const marks = [
14730
+ ...project.root === current ? ["*"] : [],
14731
+ ...project.root === list.active ? ["P"] : [],
14732
+ ...project.state === "ok" ? [] : ["!"]
14733
+ ].join("");
14734
+ const state = project.state === "ok" ? "" : ` [${project.state}]`;
14735
+ return `${marks === "" ? " " : marks} 《${project.title}》 — ${project.root}${state}`;
14736
+ }),
14737
+ "* 当前会话 · P 面板默认 · ! 目录缺失/待修复"
14738
+ ];
14739
+ return ok(lines.join("\n"));
14740
+ }
14741
+ case "current": {
14742
+ const current = await engine.currentProject();
14743
+ if (current.selection === null) return ok(`当前会话未绑定;也没有可唯一推导的项目。${current.error === void 0 ? "" : `\n${current.error}`}\n运行 /novel use <path|title|.> 选择项目。`);
14744
+ return ok(current.bound ? [
14745
+ `当前会话绑定《${current.selection.title ?? ""}》${current.state === void 0 || current.state === "ok" ? "" : ` [${current.state}]`}`,
14746
+ `项目根 ${current.selection.root}`,
14747
+ ...current.state === "missing" ? ["项目目录不在了;绑定保留,恢复目录后可继续,或 /novel unbind 后重选。"] : current.state === "not-project" ? ["目录存在,但没有 novel.yaml 或项目状态;请恢复项目定义或重新选择。"] : current.state === "damaged" ? ["项目定义不可读;先用显式 project_root 的 novel_amend 修复。"] : current.state === "unreadable" ? ["项目目录不可读;修复权限或 IO 错误后再试。"] : []
14748
+ ].join("\n") : `当前会话未绑定;将按 ${current.selection.source} 推导到 ${current.selection.root}\n要固定选择,运行 /novel use ${current.selection.root}`);
14749
+ }
14750
+ case "use": {
14751
+ const target = raw.trim().slice(3).trim();
14752
+ if (target === "") return bad("用法:/novel use <path|title|.>");
14753
+ const result = await engine.useProject({ target });
14754
+ return ok(`当前会话已切换到《${result.title}》\n项目根 ${result.selection.root}\n面板 active 未改变;后续工具调用会默认使用这本书。`);
14755
+ }
14756
+ case "unbind": {
14757
+ const result = await engine.unbindProject();
14758
+ return ok(result.cleared ? "已清除当前会话绑定;项目和注册表未改动。" : "当前会话没有绑定;项目和注册表未改动。");
14759
+ }
14760
+ case "scan": {
14761
+ const flags = rest.filter((part) => part.startsWith("--"));
14762
+ const positional = rest.filter((part) => !part.startsWith("--"));
14763
+ if (flags.filter((flag) => flag !== "--register").length > 0 || positional.length > 0) return bad("用法:/novel scan [--register];scan 不接受路径参数");
14764
+ const cwd = invocation.cwd;
14765
+ if (cwd === void 0 || cwd === "") return bad("请在项目工作区中执行 /novel scan");
14766
+ const register = flags.includes("--register");
14767
+ const result = await engine.scanProjects({
14768
+ cwd,
14769
+ register
14770
+ });
14771
+ const lines = [`扫描到 ${result.candidates.length} 个候选;${result.candidates.filter((row) => row.registered).length} 个已登记${result.truncated ? "(只显示前 50 个,请缩小工作区)" : ""}`, ...result.candidates.map((candidate) => {
14772
+ const state = candidate.state === "ok" ? "" : ` [${candidate.state}]`;
14773
+ const registered = candidate.registered ? " 已登记" : "";
14774
+ const identity = candidate.identity_changed === true ? " ! identity_changed" : "";
14775
+ return `- 《${candidate.title}》 — ${candidate.root}${state}${registered}${identity}`;
14776
+ })];
14777
+ if (result.candidates.length === 0) lines.push("未发现项目;检查当前目录,或用 novel_init 创建第一本书。");
14778
+ if (register) {
14779
+ lines.push(`登记结果:新增 ${result.added.length} · 已存在 ${result.already.length} · 跳过 ${result.skipped.length}`);
14780
+ if (result.identity_changed.length > 0) lines.push(`identity_changed:${result.identity_changed.join(", ")};这些行没有被覆盖,请用显式 register/activate 确认替换。`);
14781
+ for (const skipped of result.skipped) lines.push(`跳过 ${skipped.root}:${skipped.reason}`);
14782
+ lines.push("登记不等于选择,写作请用 /novel use 或 project_root。");
14783
+ }
14784
+ return ok(lines.join("\n"));
14785
+ }
14786
+ case "resume": {
14787
+ const { data } = await engine.resumePrompt({});
14788
+ if (data.text === null) return bad("当前项目没有可续写的下一步;先用 /novel status 查看项目状态。");
14789
+ return ok(data.text);
14790
+ }
12414
14791
  case "admin": {
12415
14792
  if (rest.length === 0) return ok(adminHelp());
12416
14793
  const text = raw.trim();
@@ -12427,12 +14804,27 @@ function createCommands(engine) {
12427
14804
  return failure(error);
12428
14805
  }
12429
14806
  };
14807
+ const withInvocation = async (invocation, task) => {
14808
+ const caller = {};
14809
+ if (typeof invocation.agent?.id === "string" && invocation.agent.id !== "") caller.sessionId = invocation.agent.id;
14810
+ if (invocation.cwd !== void 0 && invocation.cwd !== "") caller.cwd = invocation.cwd;
14811
+ else if (invocation.agent !== void 0 && host.workingDirectory !== void 0) try {
14812
+ caller.cwd = await host.workingDirectory.ensure(invocation.agent, invocation.signal);
14813
+ } catch {}
14814
+ const enriched = {
14815
+ ...invocation,
14816
+ ...caller.cwd === void 0 ? {} : { cwd: caller.cwd }
14817
+ };
14818
+ const run = () => task(enriched);
14819
+ const withCaller = engine.withCaller;
14820
+ return typeof withCaller === "function" ? withCaller.call(engine, caller, run) : run();
14821
+ };
12430
14822
  return [
12431
14823
  {
12432
14824
  name: "novel",
12433
14825
  description: "Novel engine: status, next step, stats, export and admin help.",
12434
- input: { hint: "[status|next|stats|export <kind>|admin]" },
12435
- handler: (invocation) => dispatch(invocation.rawInput, invocation.signal)
14826
+ input: { hint: "[status|next|stats|projects|current|use|unbind|scan|resume|export <kind>|admin]" },
14827
+ handler: (invocation) => dispatch(invocation)
12436
14828
  },
12437
14829
  {
12438
14830
  name: "novel-next",
@@ -12454,7 +14846,7 @@ function createCommands(engine) {
12454
14846
  if (invocation.signal.aborted) return bad("已取消");
12455
14847
  try {
12456
14848
  const stats = await engine.stats({});
12457
- return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.plannedChapters} 卡(覆盖 ${Math.round(stats.outlineCoverage * 100)}%)· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
14849
+ return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
12458
14850
  } catch (error) {
12459
14851
  return failure(error);
12460
14852
  }
@@ -12501,7 +14893,10 @@ function createCommands(engine) {
12501
14893
  }
12502
14894
  }
12503
14895
  }
12504
- ];
14896
+ ].map((command) => ({
14897
+ ...command,
14898
+ handler: (invocation) => withInvocation(invocation, command.handler)
14899
+ }));
12505
14900
  }
12506
14901
  // src/skills/index.ts
12507
14902
  /** Read once at module load; `apply()` itself performs no IO. */
@@ -12517,13 +14912,22 @@ function createSkill() {
12517
14912
  };
12518
14913
  }
12519
14914
  // src/remote/index.ts
12520
- /** Build the payload from one synchronized snapshot of the project. */
12521
- async function panelPayload(engine) {
14915
+ /** Build the payload for one optional panel query root. */
14916
+ async function panelPayload(engine, root) {
12522
14917
  return {
12523
- schemaVersion: 2,
12524
- ...await engine.panelBundle()
14918
+ ...await engine.panelBundle(root === void 0 ? {} : { root }),
14919
+ schemaVersion: 4
12525
14920
  };
12526
14921
  }
14922
+ const queryRoot = (url) => {
14923
+ if (url === void 0 || url === "") return void 0;
14924
+ try {
14925
+ const value = new URL(url, "http://panel.local").searchParams.get("root");
14926
+ return value === null || value.trim() === "" ? void 0 : value;
14927
+ } catch {
14928
+ return;
14929
+ }
14930
+ };
12527
14931
  const denyAll = () => 503;
12528
14932
  const rejectionText = {
12529
14933
  401: "unauthorized",
@@ -12550,7 +14954,7 @@ function panelHandler(engine, guard = denyAll) {
12550
14954
  return;
12551
14955
  }
12552
14956
  try {
12553
- const payload = await panelPayload(engine);
14957
+ const payload = await panelPayload(engine, queryRoot(request.url));
12554
14958
  const body = method === "HEAD" ? "" : JSON.stringify(payload);
12555
14959
  response.statusCode = 200;
12556
14960
  response.setHeader("content-type", "application/json; charset=utf-8");
@@ -12562,7 +14966,12 @@ function panelHandler(engine, guard = denyAll) {
12562
14966
  response.setHeader("content-type", "application/json; charset=utf-8");
12563
14967
  response.setHeader("cache-control", "no-store");
12564
14968
  response.end(JSON.stringify({
12565
- schemaVersion: 2,
14969
+ schemaVersion: 4,
14970
+ projects: [],
14971
+ selection: {
14972
+ root: null,
14973
+ source: "none"
14974
+ },
12566
14975
  error: "还没有项目:调用 novel_init 创建。",
12567
14976
  empty: true
12568
14977
  }));
@@ -12572,7 +14981,12 @@ function panelHandler(engine, guard = denyAll) {
12572
14981
  response.setHeader("content-type", "application/json; charset=utf-8");
12573
14982
  response.setHeader("cache-control", "no-store");
12574
14983
  response.end(JSON.stringify({
12575
- schemaVersion: 2,
14984
+ schemaVersion: 4,
14985
+ projects: [],
14986
+ selection: {
14987
+ root: null,
14988
+ source: "none"
14989
+ },
12576
14990
  error: error instanceof Error ? error.message : String(error)
12577
14991
  }));
12578
14992
  }
@@ -13053,13 +15467,15 @@ function apply(ctx, config) {
13053
15467
  const engine = new EngineService(engineConfig, () => loadRulepack(), { onEvent: emit });
13054
15468
  ctx.effect(() => () => engine.dispose(), `${name}: engine dispose`);
13055
15469
  ctx.provide?.("novelEngine", engine);
13056
- for (const tool of createNovelTools(engine)) {
15470
+ const serviceOf = ctx.get;
15471
+ const workingDirectory = typeof serviceOf === "function" ? serviceOf.call(ctx, "workingDirectory") : void 0;
15472
+ for (const tool of createNovelTools(engine, { workingDirectory })) {
13057
15473
  if (toolsMode === "core" && !CORE_TOOLS.includes(tool.name)) continue;
13058
15474
  ctx.tools.register(tool);
13059
15475
  }
13060
15476
  const optional = ctx;
13061
15477
  optional.inject(["commands"], (commandsCtx) => {
13062
- for (const command of createCommands(engine)) commandsCtx.effect(() => commandsCtx.commands.register(command), `${name}: command ${command.name}`);
15478
+ for (const command of createCommands(engine, { workingDirectory })) commandsCtx.effect(() => commandsCtx.commands.register(command), `${name}: command ${command.name}`);
13063
15479
  });
13064
15480
  optional.inject(["skills"], (skillsCtx) => {
13065
15481
  skillsCtx.effect(() => skillsCtx.skills.register(createSkill()), `${name}: entry skill`);
@@ -13093,4 +15509,4 @@ function connectionFence(scope) {
13093
15509
  }
13094
15510
  if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
13095
15511
  }
13096
- 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, renderProposal, verifyReference };
15512
+ 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 };