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/CHANGELOG.md +36 -1
- package/README.md +101 -49
- package/lib/client.js +276 -27
- package/lib/index.d.ts +291 -20
- package/lib/index.js +1534 -136
- package/lib/types/client/dto.d.ts +29 -3
- package/lib/types/client/panel.d.ts +1 -1
- package/lib/types/client/viewmodel.d.ts +31 -2
- package/lib/types/constants.d.ts +4 -1
- package/lib/types/types.d.ts +18 -1
- package/package.json +1 -1
- package/skills/entry.md +7 -0
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2904
|
-
|
|
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
|
|
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
|
-
|
|
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 !==
|
|
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 !==
|
|
2993
|
-
active: 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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
6967
|
-
|
|
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
|
-
*
|
|
7008
|
-
*
|
|
7009
|
-
*
|
|
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
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
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
|
|
7016
|
-
|
|
7017
|
-
} catch {
|
|
7018
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
9827
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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(
|
|
11071
|
+
return new ProjectRegistry(this.stateStore());
|
|
10127
11072
|
}
|
|
10128
|
-
/** List known projects
|
|
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
|
|
11087
|
+
projects
|
|
10134
11088
|
};
|
|
10135
11089
|
}
|
|
10136
|
-
/**
|
|
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
|
|
10139
|
-
|
|
10140
|
-
|
|
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
|
|
11123
|
+
/** Choose the panel/default project. This never binds the current session. */
|
|
10149
11124
|
async setActiveProject(input) {
|
|
10150
|
-
const
|
|
10151
|
-
|
|
10152
|
-
|
|
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
|
|
11146
|
+
/** Forget a registry row; the project directory and bindings are never touched. */
|
|
10155
11147
|
async forgetProject(input) {
|
|
10156
|
-
|
|
10157
|
-
|
|
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
|
-
|
|
10160
|
-
|
|
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
|
|
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
|
-
|
|
11191
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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) =>
|
|
13264
|
-
|
|
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
|
-
/**
|
|
13357
|
-
function createCommands(engine) {
|
|
13358
|
-
const dispatch = async (
|
|
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
|
|
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
|
|
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
|
-
|
|
13542
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 };
|