dsh-novel-write-engine 0.2.1 → 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";
@@ -26,9 +31,13 @@ const BACKUP_DIR = "backups";
26
31
  const HISTORY_DIR = "history";
27
32
  /** Brief receipts are append-only evidence, separate from content transactions. */
28
33
  const BRIEF_RECEIPTS_FILE = "state/briefs.yaml";
34
+ const OUTLINE_DIR = "outline";
29
35
  const LOCK_FILE = ".engine.lock";
30
36
  const STAGING_DIR = ".staging";
31
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";
32
41
  /** Files owned by the engine under `state/`. */
33
42
  const STATE_FILES = {
34
43
  pipeline: "pipeline.yaml",
@@ -90,6 +99,11 @@ const LIMITS = {
90
99
  maxWorldbookTextChars: 2e3,
91
100
  maxSearchQueryChars: 200
92
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";
93
107
  /** The order used to render the nine-phase progress strip. */
94
108
  const PHASE_STRIP = [
95
109
  "P1",
@@ -102,6 +116,37 @@ const PHASE_STRIP = [
102
116
  "P8",
103
117
  "P9"
104
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
+ ];
105
150
  /** The ten high-frequency tools kept when `tools.mode = core`. */
106
151
  const CORE_TOOLS = [
107
152
  "novel_init",
@@ -152,7 +197,7 @@ const SCENE_TYPES = [
152
197
  "情感爆发章",
153
198
  "纯叙事章"
154
199
  ];
155
- /** 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). */
156
201
  const OPERATIONS = [
157
202
  "initProject",
158
203
  "archiveChapter",
@@ -190,6 +235,9 @@ const OPERATIONS = [
190
235
  /** Error families map one-to-one to the three repair actions a caller can take. */
191
236
  const ERROR_CODES = {
192
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",
193
241
  PROJECT_EXISTS: "ENGINE_PROJECT_EXISTS",
194
242
  STAGE_ORDER: "ENGINE_STAGE_ORDER",
195
243
  GATE_BLOCKED: "ENGINE_GATE_BLOCKED",
@@ -2182,6 +2230,61 @@ var FsStore = class {
2182
2230
  throw error;
2183
2231
  }
2184
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
+ }
2185
2288
  async readText(relativePath) {
2186
2289
  try {
2187
2290
  return await promises.readFile(await this.safePath(relativePath), "utf8");
@@ -2202,10 +2305,33 @@ var FsStore = class {
2202
2305
  }
2203
2306
  return narrow(kind, (typeof parsed === "object" && parsed !== null && typeof parsed.schema_version === "number" ? parsed.schema_version : 1) < 1 ? migrateDocument(parsed, MIGRATIONS, 1) : parsed);
2204
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
+ }
2205
2331
  /** Write text atomically: temp file, fsync, rename, directory fsync. */
2206
2332
  async writeText(relativePath, text) {
2207
2333
  const absolute = await this.safePath(relativePath);
2208
- await promises.mkdir(dirname(absolute), { recursive: true });
2334
+ await this.ensureParent(absolute);
2209
2335
  const temp = `${absolute}.${randomUUID()}.tmp`;
2210
2336
  try {
2211
2337
  const handle = await promises.open(temp, "w");
@@ -2242,7 +2368,7 @@ var FsStore = class {
2242
2368
  /** Raw append used only by the journal (JSONL is append-only by design). */
2243
2369
  async appendText(relativePath, text) {
2244
2370
  const absolute = await this.safePath(relativePath);
2245
- await promises.mkdir(dirname(absolute), { recursive: true });
2371
+ await this.ensureParent(absolute);
2246
2372
  const handle = await promises.open(absolute, "a+");
2247
2373
  try {
2248
2374
  const info = await handle.stat();
@@ -2269,7 +2395,7 @@ var FsStore = class {
2269
2395
  }
2270
2396
  async writeBytes(relativePath, bytes) {
2271
2397
  const absolute = await this.safePath(relativePath);
2272
- await promises.mkdir(dirname(absolute), { recursive: true });
2398
+ await this.ensureParent(absolute);
2273
2399
  const temp = `${absolute}.${randomUUID()}.tmp`;
2274
2400
  try {
2275
2401
  const handle = await promises.open(temp, "w");
@@ -2809,8 +2935,37 @@ async function runTransaction(store, input) {
2809
2935
  let journaledCommitted = false;
2810
2936
  let backupMarked = false;
2811
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
+ };
2812
2965
  try {
2966
+ await assertRootPresent();
2813
2967
  for (const file of input.files) {
2968
+ await assertRootPresent();
2814
2969
  const target = joinRel(staging, file.path);
2815
2970
  if (file.bytes !== void 0) await store.writeBytes(target, file.bytes);
2816
2971
  else await store.writeText(target, file.text ?? "");
@@ -2823,6 +2978,7 @@ async function runTransaction(store, input) {
2823
2978
  if (sha256(stagedBytes.toString("base64")) !== expected.get(file.path)) throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, `staged copy of ${file.path} failed verification`);
2824
2979
  }
2825
2980
  if (input.afterVerify !== void 0) await input.afterVerify();
2981
+ await assertRootPresent();
2826
2982
  for (const file of input.files) before.set(file.path, await store.readBytes(file.path));
2827
2983
  for (const file of input.files) {
2828
2984
  const existing = before.get(file.path);
@@ -2854,6 +3010,7 @@ async function runTransaction(store, input) {
2854
3010
  backupMarked = true;
2855
3011
  backupComplete = true;
2856
3012
  }
3013
+ await assertRootPresent();
2857
3014
  const beforeHashes = Object.fromEntries(input.files.map((file) => {
2858
3015
  const bytes = before.get(file.path);
2859
3016
  return [file.path, bytes === null || bytes === void 0 ? null : sha256(Buffer.from(bytes).toString("base64"))];
@@ -2871,6 +3028,7 @@ async function runTransaction(store, input) {
2871
3028
  ...input.reason === void 0 ? {} : { reason: input.reason }
2872
3029
  });
2873
3030
  for (const [index, file] of input.files.entries()) {
3031
+ await assertRootPresent();
2874
3032
  const from = await store.safePath(joinRel(staging, file.path));
2875
3033
  const to = await store.safePath(file.path);
2876
3034
  await promises.mkdir(dirname(to), { recursive: true });
@@ -2879,6 +3037,7 @@ async function runTransaction(store, input) {
2879
3037
  renamed.push(file.path);
2880
3038
  if (input.afterRename !== void 0) await input.afterRename(file.path, index);
2881
3039
  }
3040
+ await assertRootPresent();
2882
3041
  for (const directory of targetDirectories) await syncDirectory(directory);
2883
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");
2884
3043
  await appendJournal(store, {
@@ -2900,13 +3059,14 @@ async function runTransaction(store, input) {
2900
3059
  force: true
2901
3060
  }).catch(() => void 0);
2902
3061
  } catch (error) {
2903
- const rollbackProblem = await restoreTargets(store, renamed, before);
2904
- 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), {
2905
3065
  recursive: true,
2906
3066
  force: true
2907
3067
  }).catch(() => void 0);
2908
- if (input.backup && (!backupMarked || !backupComplete && renamed.length === 0)) await store.removeTree(backupDir(input.txid)).catch(() => void 0);
2909
- 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, {
2910
3070
  txid: input.txid,
2911
3071
  at: (/* @__PURE__ */ new Date()).toISOString(),
2912
3072
  op: input.op,
@@ -2926,7 +3086,7 @@ async function runTransaction(store, input) {
2926
3086
  }
2927
3087
  }
2928
3088
  // src/store/registry.ts
2929
- const empty = () => ({
3089
+ const empty$1 = () => ({
2930
3090
  schema_version: 1,
2931
3091
  active: null,
2932
3092
  projects: []
@@ -2943,7 +3103,7 @@ var ProjectRegistry = class {
2943
3103
  /** Read the registry; a missing file is an empty registry, a corrupt one is a fault. */
2944
3104
  async read() {
2945
3105
  const text = await this.store.readText(REGISTRY_FILE);
2946
- if (text === null) return empty();
3106
+ if (text === null) return empty$1();
2947
3107
  let raw;
2948
3108
  try {
2949
3109
  raw = parse(text);
@@ -2953,20 +3113,45 @@ var ProjectRegistry = class {
2953
3113
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${REGISTRY_FILE} is not an object`);
2954
3114
  const record = raw;
2955
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
+ }
2956
3136
  return {
2957
3137
  schema_version: 1,
2958
- active: typeof record.active === "string" ? record.active : null,
2959
- 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
2960
3140
  };
2961
3141
  }
2962
3142
  /** Insert or update one project row. */
2963
3143
  async register(entry) {
2964
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;
2965
3148
  const row = {
2966
- ...entry,
3149
+ root,
3150
+ title: entry.title,
3151
+ ...created_at === void 0 || created_at === "" ? {} : { created_at },
2967
3152
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
2968
3153
  };
2969
- const rows = document.projects.filter((candidate) => candidate.root !== entry.root);
3154
+ const rows = document.projects.filter((candidate) => candidate.root !== root);
2970
3155
  rows.push(row);
2971
3156
  const next = {
2972
3157
  ...document,
@@ -2979,7 +3164,7 @@ var ProjectRegistry = class {
2979
3164
  async setActive(root) {
2980
3165
  const next = {
2981
3166
  ...await this.read(),
2982
- active: root
3167
+ active: resolve(root)
2983
3168
  };
2984
3169
  await this.write(next);
2985
3170
  return next;
@@ -2987,10 +3172,11 @@ var ProjectRegistry = class {
2987
3172
  /** Drop a row; the project on disk is untouched. */
2988
3173
  async forget(root) {
2989
3174
  const document = await this.read();
3175
+ const target = resolve(root);
2990
3176
  const next = {
2991
3177
  ...document,
2992
- projects: document.projects.filter((entry) => entry.root !== root),
2993
- active: document.active === root ? null : document.active
3178
+ projects: document.projects.filter((entry) => entry.root !== target),
3179
+ active: document.active === target ? null : document.active
2994
3180
  };
2995
3181
  await this.write(next);
2996
3182
  return next;
@@ -2999,6 +3185,132 @@ var ProjectRegistry = class {
2999
3185
  await this.store.writeText(REGISTRY_FILE, stringify(document, { lineWidth: 0 }));
3000
3186
  }
3001
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
+ };
3002
3314
  // src/assets.ts
3003
3315
  /**
3004
3316
  * The only reader of bundled assets. `apply()` performs no IO; the engine and
@@ -6717,6 +7029,17 @@ function briefStageFor(operation, stage) {
6717
7029
  }
6718
7030
  }
6719
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
+ }
6720
7043
  const STAGE_TO_PHASE = {
6721
7044
  idle: "P1",
6722
7045
  P2: "P2",
@@ -6760,8 +7083,9 @@ function projectChapter(record) {
6760
7083
  }
6761
7084
  function projectSummary(snapshot) {
6762
7085
  return {
6763
- schemaVersion: 3,
7086
+ schemaVersion: 4,
6764
7087
  panelId: PANEL_ID,
7088
+ root: snapshot.root,
6765
7089
  title: snapshot.title,
6766
7090
  genre: snapshot.genre,
6767
7091
  platform: snapshot.platform,
@@ -6803,20 +7127,260 @@ function projectChapters(snapshot) {
6803
7127
  details: draft.details
6804
7128
  });
6805
7129
  return {
6806
- schemaVersion: 3,
7130
+ schemaVersion: 4,
6807
7131
  panelId: PANEL_ID,
6808
7132
  rows
6809
7133
  };
6810
7134
  }
6811
7135
  function projectGates(snapshot) {
6812
7136
  return {
6813
- schemaVersion: 3,
7137
+ schemaVersion: 4,
6814
7138
  panelId: PANEL_ID,
6815
7139
  stage: snapshot.stage,
6816
7140
  blocking: snapshot.blocking,
6817
7141
  explanations: snapshot.blocking.map((item) => `${item.code}${item.scope === void 0 ? "" : ` (${item.scope})`}: ${item.detail}`)
6818
7142
  };
6819
7143
  }
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
+ }
7258
+ return {
7259
+ root: absolute,
7260
+ state: "not-project",
7261
+ title: "(不是项目)"
7262
+ };
7263
+ }
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
+ }
6820
7384
  // src/engine/state.ts
6821
7385
  /** A pipeline document for a brand-new project. */
6822
7386
  function emptyPipeline(patch) {
@@ -6925,7 +7489,7 @@ const STAGE_RANK = {
6925
7489
  done: 7
6926
7490
  };
6927
7491
  /** Internal, journal-tracked archive idempotency ledger; not a user state doc. */
6928
- const ARCHIVE_DIGESTS = joinRel(STATE_DIR, "archive-digests.json");
7492
+ const ARCHIVE_DIGESTS = ARCHIVE_DIGESTS_FILE;
6929
7493
  const LEDGER_PATHS = {
6930
7494
  chapters: joinRel(STATE_DIR, STATE_FILES.chapters),
6931
7495
  characters: joinRel(STATE_DIR, STATE_FILES.characters),
@@ -6963,8 +7527,8 @@ var EngineService = class {
6963
7527
  briefContextCache = /* @__PURE__ */ new WeakMap();
6964
7528
  briefs;
6965
7529
  now;
6966
- /** The project this process last touched; the panel follows it without a workspace hint. */
6967
- lastRoot = null;
7530
+ metadataMutex = new KeyedMutex();
7531
+ callerContext = new AsyncLocalStorage();
6968
7532
  constructor(config, rulepack, options = {}) {
6969
7533
  this.config = config;
6970
7534
  this.rulepackSource = typeof rulepack === "function" ? rulepack : () => rulepack;
@@ -6986,7 +7550,6 @@ var EngineService = class {
6986
7550
  this.disposed = true;
6987
7551
  this.stores.clear();
6988
7552
  this.replay.clear();
6989
- this.lastRoot = null;
6990
7553
  this.compiled = null;
6991
7554
  }
6992
7555
  get rulepack() {
@@ -6999,23 +7562,171 @@ var EngineService = class {
6999
7562
  withSignal(signal, task) {
7000
7563
  return this.signalContext.run(signal, task);
7001
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
+ }
7002
7572
  /** The configured default project root. */
7003
7573
  defaultRoot() {
7004
7574
  return resolve(this.config.projectRoot);
7005
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
+ }
7006
7591
  /**
7007
- * Resolve an omitted root through the cross-project registry. The registry is
7008
- * convenience state, so a corrupt or unreadable registry falls back to the
7009
- * 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.
7010
7595
  */
7011
- async resolveRoot(root) {
7012
- if (root !== void 0) return resolve(root);
7013
- 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() {
7014
7615
  try {
7015
- const document = await this.registry().read();
7016
- if (document.active !== null) return resolve(document.active);
7017
- } catch {}
7018
- 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());
7019
7730
  }
7020
7731
  storeFor(root) {
7021
7732
  const absolute = resolve(root ?? this.config.projectRoot);
@@ -7025,6 +7736,69 @@ var EngineService = class {
7025
7736
  this.stores.set(absolute, store);
7026
7737
  return store;
7027
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
+ }
7028
7802
  async initProject(input) {
7029
7803
  const title = typeof input.title === "string" ? input.title.trim() : "";
7030
7804
  if (title === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "title must not be empty");
@@ -7043,10 +7817,16 @@ var EngineService = class {
7043
7817
  const [min, max] = input.dialogue_ratio;
7044
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");
7045
7819
  }
7046
- return this.mutate({
7047
- 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,
7048
7826
  op: "initProject",
7049
- key: () => `init:${resolve(input.root ?? this.config.projectRoot)}`,
7827
+ bindOnSuccess: false,
7828
+ registerOnSuccess: false,
7829
+ key: () => `init:${selection.root}`,
7050
7830
  run: async (_snapshot, context) => {
7051
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");
7052
7832
  const project = {
@@ -7145,6 +7925,20 @@ var EngineService = class {
7145
7925
  },
7146
7926
  emptyAllowed: true
7147
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;
7148
7942
  }
7149
7943
  async submitBookPlan(input) {
7150
7944
  const allowed = /* @__PURE__ */ new Set([
@@ -7848,7 +8642,14 @@ var EngineService = class {
7848
8642
  async getStatus(input = {}) {
7849
8643
  if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
7850
8644
  const snapshot = await this.load(input.root);
7851
- 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) {
7852
8653
  const metrics = await this.metricsFor(snapshot, chapter);
7853
8654
  const journal = await this.journalBlocking(snapshot);
7854
8655
  const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
@@ -7870,6 +8671,10 @@ var EngineService = class {
7870
8671
  rounds: { ...snapshot.pipeline.rounds },
7871
8672
  blocking: [...this.effectiveBlocking(snapshot), ...journal],
7872
8673
  state_errors: this.stateErrors(snapshot),
8674
+ selection: {
8675
+ source: snapshot.selection.source,
8676
+ root: snapshot.selection.root
8677
+ },
7873
8678
  metrics,
7874
8679
  ...snapshot.handoff === null ? {} : (() => {
7875
8680
  const position = projectHandoff(snapshot.handoff, snapshot.pipeline.chapter);
@@ -8262,13 +9067,14 @@ var EngineService = class {
8262
9067
  };
8263
9068
  }
8264
9069
  async doctor(input = {}) {
8265
- const root = await this.resolveRoot(input.root);
9070
+ const selection = await this.resolveRootForInspection(input.root);
9071
+ const root = selection.root;
8266
9072
  const store = this.storeFor(root);
8267
9073
  const checks = [];
8268
9074
  const add = (check) => {
8269
9075
  checks.push(check);
8270
9076
  };
8271
- if (!await store.exists("novel.yaml") && !await this.projectStateExists(store)) {
9077
+ if (!await store.exists("novel.yaml") && !await hasProjectState(store)) {
8272
9078
  add({
8273
9079
  id: "project",
8274
9080
  severity: "error",
@@ -8293,7 +9099,8 @@ var EngineService = class {
8293
9099
  }
8294
9100
  let snapshot;
8295
9101
  try {
8296
- snapshot = await this.load(root);
9102
+ snapshot = await this.loadAt(selection);
9103
+ await this.recordSelectionBestEffort(selection, snapshot);
8297
9104
  } catch (error) {
8298
9105
  add({
8299
9106
  id: "load",
@@ -8588,9 +9395,10 @@ var EngineService = class {
8588
9395
  };
8589
9396
  }
8590
9397
  async guide(input = {}) {
8591
- const root = await this.resolveRoot(input.root);
9398
+ const selection = await this.resolveRootForInspection(input.root);
9399
+ const root = selection.root;
8592
9400
  const store = this.storeFor(root);
8593
- if (!await store.exists("novel.yaml") && !await this.projectStateExists(store)) return {
9401
+ if (!await store.exists("novel.yaml") && !await hasProjectState(store)) return {
8594
9402
  data: buildGuide({
8595
9403
  projectPresent: false,
8596
9404
  chapter: 1,
@@ -8646,7 +9454,8 @@ var EngineService = class {
8646
9454
  revision: 1,
8647
9455
  warnings: []
8648
9456
  };
8649
- const snapshot = await this.load(root);
9457
+ const snapshot = await this.loadAt(selection);
9458
+ await this.recordSelectionBestEffort(selection, snapshot);
8650
9459
  const target = snapshot.project.targets.total_chapters;
8651
9460
  const finished = snapshot.pipeline.stage === "done" && target !== null && snapshot.chapters.chapters.length >= target;
8652
9461
  const journalBlocking = await this.journalBlocking(snapshot);
@@ -9615,7 +10424,8 @@ var EngineService = class {
9615
10424
  if (typeof input.path !== "string" || input.path.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend path must be a non-empty string");
9616
10425
  if (typeof input.reason !== "string" || input.reason.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "amend requires a reason");
9617
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");
9618
- const resolvedRoot = await this.resolveRoot(input.root);
10427
+ const selection = await this.resolveRoot(input.root);
10428
+ const resolvedRoot = selection.root;
9619
10429
  const store = this.storeFor(resolvedRoot);
9620
10430
  const absolute = store.path(input.path);
9621
10431
  const kind = Object.hasOwn(MUTABLE_STATE, input.path) ? MUTABLE_STATE[input.path] : void 0;
@@ -9626,7 +10436,7 @@ var EngineService = class {
9626
10436
  if (await this.stateDocIsUnreadable(store, path, docKind)) repairPaths.push(path);
9627
10437
  }
9628
10438
  return this.mutate({
9629
- root: resolvedRoot,
10439
+ selection,
9630
10440
  op: "amend",
9631
10441
  reason: input.reason,
9632
10442
  ...repairPaths.length === 0 ? {} : { repairPaths },
@@ -9823,15 +10633,8 @@ var EngineService = class {
9823
10633
  if (input.before !== void 0 && typeof input.before !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "diff before must be a string");
9824
10634
  if (input.after !== void 0 && typeof input.after !== "string") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "diff after must be a string");
9825
10635
  if (input.before !== void 0 && input.after !== void 0) {
9826
- let chapter = input.chapter;
9827
- let revision = 1;
9828
- if (chapter === void 0) try {
9829
- const snapshot = await this.load(input.root);
9830
- chapter = snapshot.pipeline.chapter;
9831
- revision = snapshot.pipeline.revision;
9832
- } catch {
9833
- chapter = 0;
9834
- }
10636
+ const chapter = input.chapter ?? 0;
10637
+ const revision = 1;
9835
10638
  const report = diffLines(input.before, input.after);
9836
10639
  return {
9837
10640
  data: {
@@ -10088,13 +10891,155 @@ var EngineService = class {
10088
10891
  async panelGates(input = {}) {
10089
10892
  return projectGates(await this.panelSnapshot(input.root));
10090
10893
  }
10091
- /** 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
+ */
10092
10899
  async panelBundle(input = {}) {
10093
- 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
+ }) };
10094
11014
  return {
10095
- summary: projectSummary(snapshot),
11015
+ projects,
11016
+ selection,
11017
+ summary,
10096
11018
  chapters: projectChapters(snapshot),
10097
- 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: []
10098
11043
  };
10099
11044
  }
10100
11045
  async stats(input = {}) {
@@ -10123,42 +11068,287 @@ var EngineService = class {
10123
11068
  };
10124
11069
  }
10125
11070
  registry() {
10126
- return new ProjectRegistry(new FsStore(this.config.stateDir === "" ? join(homedir(), ".dsh", "novel-write-engine") : this.config.stateDir));
11071
+ return new ProjectRegistry(this.stateStore());
10127
11072
  }
10128
- /** List known projects (convenience only; a project opens without a row). */
11073
+ /** List known projects with a cheap directory state for the panel/command. */
10129
11074
  async listProjects() {
10130
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
+ }));
10131
11085
  return {
10132
11086
  active: document.active,
10133
- projects: document.projects
11087
+ projects
10134
11088
  };
10135
11089
  }
10136
- /** 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
+ */
10137
11094
  async registerProject(input = {}) {
10138
- const snapshot = await this.load(input.root);
10139
- const document = await this.registry().register({
10140
- root: snapshot.root,
10141
- title: snapshot.project.title
10142
- });
10143
- return {
10144
- active: document.active,
10145
- projects: document.projects
11095
+ const selection = input.root === void 0 ? await this.resolveRoot(void 0) : {
11096
+ root: this.resolveProjectPath(input.root),
11097
+ source: "explicit"
10146
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
+ });
10147
11122
  }
10148
- /** Choose the default project for future convenience calls. */
11123
+ /** Choose the panel/default project. This never binds the current session. */
10149
11124
  async setActiveProject(input) {
10150
- const document = await this.registry().setActive(resolve(input.root));
10151
- this.lastRoot = document.active === null ? null : resolve(document.active);
10152
- 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
+ });
10153
11145
  }
10154
- /** Forget a registry row; the project directory is never touched. */
11146
+ /** Forget a registry row; the project directory and bindings are never touched. */
10155
11147
  async forgetProject(input) {
10156
- const document = await this.registry().forget(resolve(input.root));
10157
- 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
+ });
10158
11263
  return {
10159
- active: document.active,
10160
- 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"
10161
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
11345
+ };
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 };
10162
11352
  }
10163
11353
  /**
10164
11354
  * Import one ledger from a settings pack. Always previews with a diff; a
@@ -10310,7 +11500,8 @@ var EngineService = class {
10310
11500
  };
10311
11501
  }
10312
11502
  async panelSnapshot(root) {
10313
- const snapshot = await this.load(root);
11503
+ const selection = await this.resolveRoot(root);
11504
+ const snapshot = await this.loadAt(selection);
10314
11505
  const archived = snapshot.chapters.chapters;
10315
11506
  const archivedCurrent = archived.some((chapter) => chapter.num === snapshot.pipeline.chapter);
10316
11507
  const target = snapshot.project.targets.total_chapters;
@@ -11161,22 +12352,6 @@ var EngineService = class {
11161
12352
  this.assertChapterParameter(chapter);
11162
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");
11163
12354
  }
11164
- /** True when anything survives in the project directory besides novel.yaml. */
11165
- async projectStateExists(store) {
11166
- const stateFiles = [
11167
- STATE_FILES.pipeline,
11168
- STATE_FILES.chapters,
11169
- STATE_FILES.characters,
11170
- STATE_FILES.world,
11171
- STATE_FILES.locks,
11172
- STATE_FILES.timeline,
11173
- STATE_FILES.foreshadows,
11174
- STATE_FILES.relationships,
11175
- STATE_FILES.handoff
11176
- ].map((file) => joinRel(STATE_DIR, file));
11177
- stateFiles.push(joinRel(STATE_DIR, STATE_FILES.journal), ARCHIVE_DIGESTS, BRIEF_RECEIPTS_FILE);
11178
- 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");
11179
- }
11180
12355
  /** A live writer lock makes a read-only snapshot potentially torn. */
11181
12356
  async lockIsLive(store) {
11182
12357
  const text = await store.readText(LOCK_FILE);
@@ -11187,8 +12362,34 @@ var EngineService = class {
11187
12362
  const info = await store.stat(LOCK_FILE);
11188
12363
  return info !== null && Date.now() - info.mtimeMs < 1e3;
11189
12364
  }
11190
- async load(root, retries = 1, allowLock = false) {
11191
- 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
+ }
11192
12393
  if (!allowLock && await this.lockIsLive(store)) {
11193
12394
  for (let attempt = 0; attempt < 10; attempt += 1) {
11194
12395
  await new Promise((resolve) => setTimeout(resolve, 25));
@@ -11197,10 +12398,17 @@ var EngineService = class {
11197
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");
11198
12399
  }
11199
12400
  const projectExists = await store.exists(PROJECT_FILE);
11200
- 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
+ }
11201
12408
  let project = {
11202
12409
  ...ENGINE_PROJECT_INITIAL,
11203
- title: "(项目文件不可读)"
12410
+ title: "(项目文件不可读)",
12411
+ created_at: ""
11204
12412
  };
11205
12413
  let projectError = projectExists ? null : `${PROJECT_FILE} is missing`;
11206
12414
  if (projectExists) try {
@@ -11211,6 +12419,13 @@ var EngineService = class {
11211
12419
  if (error instanceof EngineFaultError && (error.code === ERROR_CODES.SCHEMA_TOO_NEW || error.code === ERROR_CODES.PATH_ESCAPE)) throw error;
11212
12420
  projectError = errorMessage(error);
11213
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
+ }
11214
12429
  const pipeline = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
11215
12430
  if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
11216
12431
  let briefs = pipeline.briefs;
@@ -11347,16 +12562,16 @@ var EngineService = class {
11347
12562
  if (check === null && checkError === null && pipeline.stage === "ready_to_archive") checkError = `${checkFile(chapter)} is missing`;
11348
12563
  const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
11349
12564
  if (latest === null || latest.revision !== pipeline.revision) {
11350
- if (retries > 0) return this.load(store.root, retries - 1, allowLock);
12565
+ if (retries > 0) return this.loadAt(selection, retries - 1, allowLock);
11351
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");
11352
12567
  }
11353
12568
  if (!allowLock && await this.lockIsLive(store)) {
11354
- if (retries > 0) return this.load(store.root, retries - 1, false);
12569
+ if (retries > 0) return this.loadAt(selection, retries - 1, false);
11355
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");
11356
12571
  }
11357
- this.lastRoot = store.root;
11358
12572
  return {
11359
12573
  root: store.root,
12574
+ selection,
11360
12575
  project,
11361
12576
  projectError,
11362
12577
  pipeline,
@@ -11421,7 +12636,8 @@ var EngineService = class {
11421
12636
  }
11422
12637
  }
11423
12638
  async mutate(options) {
11424
- const root = await this.resolveRoot(options.root);
12639
+ const selection = options.selection ?? await this.resolveRoot(options.root);
12640
+ const root = selection.root;
11425
12641
  const signal = this.signalContext.getStore();
11426
12642
  const abortIfNeeded = () => {
11427
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");
@@ -11430,9 +12646,24 @@ var EngineService = class {
11430
12646
  return this.mutex.run(root, async () => {
11431
12647
  if (options.emptyAllowed === true) ensureDirAt(root);
11432
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
+ }
11433
12663
  const journalPath = joinRel(STATE_DIR, STATE_FILES.journal);
11434
12664
  const journalWasPresent = await store.exists(journalPath);
11435
12665
  const projectExists = await store.exists(PROJECT_FILE);
12666
+ if (options.emptyAllowed !== true && !projectExists && !await hasProjectState(store)) throw projectNotFoundError(root);
11436
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");
11437
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");
11438
12669
  const lock = new FileLock(join(root, LOCK_FILE), void 0, void 0, async (record) => {
@@ -11466,7 +12697,7 @@ var EngineService = class {
11466
12697
  const stateCandidates = journalWasPresent ? stateFiles : stateFiles.filter((path) => path !== joinRel(STATE_DIR, STATE_FILES.journal));
11467
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");
11468
12699
  await assertJournalConsistent(store);
11469
- const created = await options.run(this.emptySnapshot(root), {
12700
+ const created = await options.run(this.emptySnapshot(root, selection), {
11470
12701
  store,
11471
12702
  revision: 1
11472
12703
  });
@@ -11481,7 +12712,6 @@ var EngineService = class {
11481
12712
  ...options.reason === void 0 ? {} : { reason: options.reason }
11482
12713
  });
11483
12714
  this.replay.clear();
11484
- this.lastRoot = root;
11485
12715
  return {
11486
12716
  data: created.data,
11487
12717
  revision: 1,
@@ -11490,7 +12720,11 @@ var EngineService = class {
11490
12720
  }
11491
12721
  }
11492
12722
  await assertJournalConsistent(store, options.repairPaths ?? []);
11493
- 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
+ });
11494
12728
  const key = options.key?.(snapshot);
11495
12729
  if (key !== void 0) {
11496
12730
  const cached = this.replay.get(`${root}|${options.op}|${key}`);
@@ -11549,6 +12783,21 @@ var EngineService = class {
11549
12783
  revision,
11550
12784
  warnings: outcome.warnings ?? []
11551
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
+ }
11552
12801
  if (key !== void 0) {
11553
12802
  this.invalidateReplay(root, options.op);
11554
12803
  this.replay.set(`${root}|${options.op}|${key}`, result);
@@ -11568,9 +12817,10 @@ var EngineService = class {
11568
12817
  if (contentChanging || !key.includes("|export|")) this.replay.delete(key);
11569
12818
  }
11570
12819
  }
11571
- emptySnapshot(root) {
12820
+ emptySnapshot(root, selection) {
11572
12821
  return {
11573
12822
  root,
12823
+ selection,
11574
12824
  project: { ...ENGINE_PROJECT_INITIAL },
11575
12825
  projectError: null,
11576
12826
  pipeline: emptyPipeline({
@@ -12308,10 +13558,17 @@ const fail = (error) => {
12308
13558
  }
12309
13559
  throw error;
12310
13560
  };
12311
- /** Wrap an execute body with cancellation and a machine-code error. */
12312
- 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 {}
12313
13568
  try {
12314
- 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());
12315
13572
  } catch (error) {
12316
13573
  return fail(error);
12317
13574
  }
@@ -12360,14 +13617,22 @@ const stringArray = {
12360
13617
  };
12361
13618
  const root = {
12362
13619
  type: "string",
12363
- 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."
12364
13629
  };
12365
13630
  /**
12366
13631
  * The twenty-eight tools are thin adapters: narrow parameters, call one service
12367
13632
  * operation, render the canonical value. They never read a file.
12368
13633
  */
12369
- function createNovelTools(engine) {
12370
- const guarded = makeGuarded(engine);
13634
+ function createNovelTools(engine, options = {}) {
13635
+ const guarded = makeGuarded(engine, options.workingDirectory);
12371
13636
  return [
12372
13637
  defineTool({
12373
13638
  name: "novel_init",
@@ -12406,7 +13671,7 @@ function createNovelTools(engine) {
12406
13671
  selling_points: stringArray,
12407
13672
  ending: { type: "string" },
12408
13673
  strict_outline: { type: "boolean" },
12409
- project_root: root
13674
+ project_root: initRoot
12410
13675
  },
12411
13676
  output: jsonOutput,
12412
13677
  execute: guarded(async (args) => engine.initProject({
@@ -12444,6 +13709,7 @@ function createNovelTools(engine) {
12444
13709
  const where = active === value.chapter ? `第 ${value.chapter} 章` : `当前第 ${active} 章 · 查询第 ${value.chapter} 章字数`;
12445
13710
  return text([
12446
13711
  `《${value.project.title}》 ${where} · ${value.stage}`,
13712
+ ...value.selection === void 0 ? [] : [`项目根 ${value.selection.root} · 选择来源 ${value.selection.source}`],
12447
13713
  ...value.rounds === void 0 ? [] : [`轮次:评审 ${value.rounds.review} / 修订 ${value.rounds.revise} / 校验 ${value.rounds.check}`],
12448
13714
  value.blocking.length === 0 ? "门控:全部通过" : `门控:${value.blocking.length} 条阻塞`,
12449
13715
  ...value.blocking.map((item) => ` - ${item.code}: ${item.detail}`),
@@ -12755,7 +14021,7 @@ function createNovelTools(engine) {
12755
14021
  parameters: {
12756
14022
  text: { type: "string" },
12757
14023
  chapter: { type: "integer" },
12758
- project_root: root
14024
+ project_root: utilityRoot
12759
14025
  },
12760
14026
  output: jsonOutput,
12761
14027
  execute: guarded(async (args) => engine.countWords({
@@ -12893,7 +14159,7 @@ function createNovelTools(engine) {
12893
14159
  chapter: { type: "integer" },
12894
14160
  before: { type: "string" },
12895
14161
  after: { type: "string" },
12896
- project_root: root
14162
+ project_root: utilityRoot
12897
14163
  },
12898
14164
  output: jsonOutput,
12899
14165
  execute: guarded(async (args) => engine.diff({
@@ -13260,8 +14526,21 @@ const OPERATIONS$1 = {
13260
14526
  novel_reopen: (engine, args) => engine.reopenChapter(args),
13261
14527
  novel_restore: (engine, args) => engine.restoreBackup(args),
13262
14528
  novel_registry_list: (engine) => engine.listProjects(),
13263
- novel_registry_register: (engine, args) => engine.registerProject({ root: rootArg(args) }),
13264
- 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
+ },
13265
14544
  novel_registry_forget: (engine, args) => engine.forgetProject({ root: rootArg(args) }),
13266
14545
  novel_import: (engine, args) => engine.importPack(args)
13267
14546
  };
@@ -13328,6 +14607,7 @@ const statusText = async (engine) => {
13328
14607
  return [
13329
14608
  `《${data.project.title}》 ${data.project.genre} · ${data.project.platform}`,
13330
14609
  `第 ${data.chapter} 章 · 阶段 ${data.stage}`,
14610
+ ...data.selection === void 0 ? [] : [`项目根 ${data.selection.root} · 选择来源 ${data.selection.source}`],
13331
14611
  `轮次 评审 ${data.rounds.review} / 修订 ${data.rounds.revise} / 校验 ${data.rounds.check}`,
13332
14612
  data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}(汉字 ${data.metrics.cjk_chars} + 标点 ${data.metrics.punctuation_chars},含标点)`,
13333
14613
  data.blocking.length === 0 ? "门控 全部通过" : `门控 ${data.blocking.length} 条阻塞:`,
@@ -13346,6 +14626,12 @@ const usage = () => [
13346
14626
  "/novel doctor 项目体检",
13347
14627
  "/novel search <词> 搜索正文/大纲/世界观/台账",
13348
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 输出可粘贴的续写指令",
13349
14635
  "/novel admin 隐藏操作与参数说明",
13350
14636
  "/novel admin <op> <json> 执行隐藏操作",
13351
14637
  "/novel-next 等价于 /novel next",
@@ -13353,9 +14639,10 @@ const usage = () => [
13353
14639
  "/novel-export <kind>",
13354
14640
  "/novel-admin [<op> <json>]"
13355
14641
  ].join("\n");
13356
- /** The five slash commands; every one calls a service operation, none touches a file. */
13357
- function createCommands(engine) {
13358
- 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;
13359
14646
  if (signal.aborted) return bad("已取消");
13360
14647
  const [head = "", ...rest] = raw.trim().split(/\s+/).filter((part) => part !== "");
13361
14648
  try {
@@ -13429,6 +14716,78 @@ function createCommands(engine) {
13429
14716
  const { data } = await engine.export({ kind });
13430
14717
  return ok(`已导出 ${data.kind}:${data.files.join(", ")}`);
13431
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
+ }
13432
14791
  case "admin": {
13433
14792
  if (rest.length === 0) return ok(adminHelp());
13434
14793
  const text = raw.trim();
@@ -13445,12 +14804,27 @@ function createCommands(engine) {
13445
14804
  return failure(error);
13446
14805
  }
13447
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
+ };
13448
14822
  return [
13449
14823
  {
13450
14824
  name: "novel",
13451
14825
  description: "Novel engine: status, next step, stats, export and admin help.",
13452
- input: { hint: "[status|next|stats|export <kind>|admin]" },
13453
- 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)
13454
14828
  },
13455
14829
  {
13456
14830
  name: "novel-next",
@@ -13519,7 +14893,10 @@ function createCommands(engine) {
13519
14893
  }
13520
14894
  }
13521
14895
  }
13522
- ];
14896
+ ].map((command) => ({
14897
+ ...command,
14898
+ handler: (invocation) => withInvocation(invocation, command.handler)
14899
+ }));
13523
14900
  }
13524
14901
  // src/skills/index.ts
13525
14902
  /** Read once at module load; `apply()` itself performs no IO. */
@@ -13535,13 +14912,22 @@ function createSkill() {
13535
14912
  };
13536
14913
  }
13537
14914
  // src/remote/index.ts
13538
- /** Build the payload from one synchronized snapshot of the project. */
13539
- async function panelPayload(engine) {
14915
+ /** Build the payload for one optional panel query root. */
14916
+ async function panelPayload(engine, root) {
13540
14917
  return {
13541
- schemaVersion: 3,
13542
- ...await engine.panelBundle()
14918
+ ...await engine.panelBundle(root === void 0 ? {} : { root }),
14919
+ schemaVersion: 4
13543
14920
  };
13544
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
+ };
13545
14931
  const denyAll = () => 503;
13546
14932
  const rejectionText = {
13547
14933
  401: "unauthorized",
@@ -13568,7 +14954,7 @@ function panelHandler(engine, guard = denyAll) {
13568
14954
  return;
13569
14955
  }
13570
14956
  try {
13571
- const payload = await panelPayload(engine);
14957
+ const payload = await panelPayload(engine, queryRoot(request.url));
13572
14958
  const body = method === "HEAD" ? "" : JSON.stringify(payload);
13573
14959
  response.statusCode = 200;
13574
14960
  response.setHeader("content-type", "application/json; charset=utf-8");
@@ -13580,7 +14966,12 @@ function panelHandler(engine, guard = denyAll) {
13580
14966
  response.setHeader("content-type", "application/json; charset=utf-8");
13581
14967
  response.setHeader("cache-control", "no-store");
13582
14968
  response.end(JSON.stringify({
13583
- schemaVersion: 3,
14969
+ schemaVersion: 4,
14970
+ projects: [],
14971
+ selection: {
14972
+ root: null,
14973
+ source: "none"
14974
+ },
13584
14975
  error: "还没有项目:调用 novel_init 创建。",
13585
14976
  empty: true
13586
14977
  }));
@@ -13590,7 +14981,12 @@ function panelHandler(engine, guard = denyAll) {
13590
14981
  response.setHeader("content-type", "application/json; charset=utf-8");
13591
14982
  response.setHeader("cache-control", "no-store");
13592
14983
  response.end(JSON.stringify({
13593
- schemaVersion: 3,
14984
+ schemaVersion: 4,
14985
+ projects: [],
14986
+ selection: {
14987
+ root: null,
14988
+ source: "none"
14989
+ },
13594
14990
  error: error instanceof Error ? error.message : String(error)
13595
14991
  }));
13596
14992
  }
@@ -14071,13 +15467,15 @@ function apply(ctx, config) {
14071
15467
  const engine = new EngineService(engineConfig, () => loadRulepack(), { onEvent: emit });
14072
15468
  ctx.effect(() => () => engine.dispose(), `${name}: engine dispose`);
14073
15469
  ctx.provide?.("novelEngine", engine);
14074
- 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 })) {
14075
15473
  if (toolsMode === "core" && !CORE_TOOLS.includes(tool.name)) continue;
14076
15474
  ctx.tools.register(tool);
14077
15475
  }
14078
15476
  const optional = ctx;
14079
15477
  optional.inject(["commands"], (commandsCtx) => {
14080
- 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}`);
14081
15479
  });
14082
15480
  optional.inject(["skills"], (skillsCtx) => {
14083
15481
  skillsCtx.effect(() => skillsCtx.skills.register(createSkill()), `${name}: entry skill`);
@@ -14111,4 +15509,4 @@ function connectionFence(scope) {
14111
15509
  }
14112
15510
  if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
14113
15511
  }
14114
- 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 };