@hanna84/mcp-writing 1.11.0 → 1.11.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 CHANGED
@@ -4,11 +4,31 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v1.11.2](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.11.1...v1.11.2)
9
+
10
+ - Scrivener beta: opt-in chapter organization + raw keyword tags [`#60`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/60)
12
+
13
+ #### [v1.11.1](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.11.0...v1.11.1)
15
+
16
+ > 22 April 2026
17
+
18
+ - Implement graceful batch cancellation and complete Phase D coverage [`#59`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/59)
20
+ - Release 1.11.1 [`85bb5c6`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/85bb5c6c3643256eb6866c7b75cc3e3f87ce1412)
22
+
7
23
  #### [v1.11.0](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.10.0...v1.11.0)
9
25
 
26
+ > 21 April 2026
27
+
10
28
  - feat: add reusable manual real-data Scrivener test runner [`#58`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/58)
30
+ - Release 1.11.0 [`5938d51`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/5938d51b277dcce2f5fcfe728ff2112d3af644e5)
12
32
 
13
33
  #### [v1.10.0](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.9.4...v1.10.0)
package/README.md CHANGED
@@ -76,5 +76,17 @@ Goal: let AI propose prose edits without losing control of your draft.
76
76
 
77
77
  Outcome: you get AI speed with explicit approval and recoverable history for every applied change.
78
78
 
79
+ ### 5) Refreshing scene-character links after imports or major rewrites
80
+
81
+ Goal: rebuild scene-to-character links in a controlled way after imported prose changes or metadata drift.
82
+
83
+ 1. Start with `enrich_scene_characters_batch` using the default `dry_run=true` to preview inferred links for a project, chapter, or explicit scene list.
84
+ 2. Poll `get_async_job_status` until the batch job completes, then review `job.result.results` for changed scenes, ambiguous matches, and partial failures.
85
+ 3. Spot-check a few affected scenes with `get_scene_prose` if the changes touch important continuity or cast-heavy chapters.
86
+ 4. Re-run `enrich_scene_characters_batch` with `dry_run=false` once the preview looks correct.
87
+ 5. If you want a destructive overwrite instead of additive merge behavior, use `replace_mode=replace` with `confirm_replace=true` deliberately.
88
+
89
+ Outcome: character-link maintenance becomes a preview-first batch operation instead of a one-off regex script or manual sidecar cleanup.
90
+
79
91
  ## License
80
92
  AGPL-3.0-only
package/db.js CHANGED
@@ -18,6 +18,7 @@ export const SCHEMA = `
18
18
  title TEXT,
19
19
  part INTEGER,
20
20
  chapter INTEGER,
21
+ chapter_title TEXT,
21
22
  pov TEXT,
22
23
  logline TEXT,
23
24
  scene_change TEXT,
@@ -124,6 +125,11 @@ export function openDb(dbPath) {
124
125
  const db = new DatabaseSync(dbPath);
125
126
  db.exec(SCHEMA);
126
127
 
128
+ const sceneColumns = db.prepare(`PRAGMA table_info(scenes)`).all();
129
+ if (!sceneColumns.some(column => column.name === "chapter_title")) {
130
+ db.exec(`ALTER TABLE scenes ADD COLUMN chapter_title TEXT;`);
131
+ }
132
+
127
133
  // Rebuild legacy FTS table if it predates keyword indexing.
128
134
  // Preserve existing indexed rows so metadata search remains available
129
135
  // even before the next sync pass repopulates from source files.
package/importer.js CHANGED
@@ -107,20 +107,35 @@ function loadYamlFile(filePath) {
107
107
  }
108
108
  }
109
109
 
110
+ function walkSidecarFiles(dir, fileList = []) {
111
+ if (!fs.existsSync(dir)) return fileList;
112
+
113
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
114
+ const full = path.join(dir, entry.name);
115
+ if (entry.isDirectory()) {
116
+ // Skip nested mirror trees under scenes/ (e.g. scenes/projects/... or scenes/universes/...)
117
+ // to avoid accidental reconciliation against duplicated sidecars.
118
+ if (/^(projects|universes)$/i.test(entry.name)) continue;
119
+ walkSidecarFiles(full, fileList);
120
+ } else if (entry.isFile() && entry.name.endsWith(".meta.yaml")) {
121
+ fileList.push(full);
122
+ }
123
+ }
124
+
125
+ return fileList;
126
+ }
127
+
110
128
  function buildExistingSceneIndex(dir) {
111
129
  const byBinderId = new Map();
112
130
  if (!fs.existsSync(dir)) return byBinderId;
113
131
 
114
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
115
- if (!entry.isFile() || !entry.name.endsWith(".meta.yaml")) continue;
116
-
117
- const sidecarPath = path.join(dir, entry.name);
132
+ for (const sidecarPath of walkSidecarFiles(dir)) {
118
133
  const proseCandidates = [
119
134
  sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
120
135
  sidecarPath.replace(/\.meta\.yaml$/, ".md"),
121
136
  ];
122
137
  const prosePath = proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
123
- const proseName = prosePath ? path.basename(prosePath) : entry.name.replace(/\.meta\.yaml$/, ".txt");
138
+ const proseName = prosePath ? path.basename(prosePath) : path.basename(sidecarPath).replace(/\.meta\.yaml$/, ".txt");
124
139
  const parsedName = parseFilename(proseName);
125
140
  const meta = loadYamlFile(sidecarPath);
126
141
  const binderId = meta.external_source === "scrivener" && meta.external_id
@@ -342,7 +357,12 @@ export function importScrivenerSync({
342
357
  const title = cleanTitle(rawTitle);
343
358
  const existingScene = existingScenes.get(String(binderId)) ?? null;
344
359
  const sceneId = existingScene?.meta?.scene_id ?? makeSceneId(binderId, title);
345
- const destFile = path.join(scenesDir, `${seq.toString().padStart(3, "0")} ${rawTitle} [${binderId}].${ext}`);
360
+ const targetDir = existingScene?.prosePath
361
+ ? path.dirname(existingScene.prosePath)
362
+ : existingScene?.sidecarPath
363
+ ? path.dirname(existingScene.sidecarPath)
364
+ : scenesDir;
365
+ const destFile = path.join(targetDir, `${seq.toString().padStart(3, "0")} ${rawTitle} [${binderId}].${ext}`);
346
366
  const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
347
367
 
348
368
  const meta = {
@@ -366,6 +386,7 @@ export function importScrivenerSync({
366
386
  }
367
387
  logger(` scene_id: ${sceneId}, beat: ${beatCarry ?? "(none)"}`);
368
388
  } else {
389
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
369
390
  fs.copyFileSync(file, destFile);
370
391
  fs.writeFileSync(sidecar, yaml.dump(meta, { lineWidth: 120 }), "utf8");
371
392
 
package/index.js CHANGED
@@ -17,6 +17,7 @@ import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, lis
17
17
  import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
18
18
  import { importScrivenerSync, validateProjectId } from "./importer.js";
19
19
  import { mergeScrivenerProjectMetadata } from "./scrivener-direct.js";
20
+ import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
20
21
 
21
22
  const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
22
23
  const DB_PATH = process.env.DB_PATH ?? "./writing.db";
@@ -119,12 +120,14 @@ function toPublicJob(job, includeResult = true) {
119
120
  finished_at: job.finishedAt,
120
121
  pid: job.pid,
121
122
  error: job.error,
123
+ ...(job.progress ? { progress: job.progress } : {}),
122
124
  ...(includeResult ? { result: job.result } : {}),
123
125
  };
124
126
  }
125
127
 
126
128
  function startAsyncJob({ kind, requestPayload, onComplete }) {
127
129
  pruneAsyncJobs();
130
+ const progressPrefix = ASYNC_PROGRESS_PREFIX;
128
131
 
129
132
  const id = randomUUID();
130
133
  const tmpPrefix = path.join(os.tmpdir(), "mcp-writing-job-");
@@ -156,14 +159,38 @@ function startAsyncJob({ kind, requestPayload, onComplete }) {
156
159
  requestPath,
157
160
  resultPath,
158
161
  result: null,
162
+ progress: null,
159
163
  error: null,
160
164
  onComplete,
161
165
  child,
162
166
  };
163
167
  asyncJobs.set(id, job);
164
168
 
165
- child.stdout.on("data", () => {
166
- // worker writes structured output to resultPath; stdout is ignored here
169
+ let stdoutBuffer = "";
170
+ child.stdout.on("data", (chunk) => {
171
+ stdoutBuffer += chunk.toString("utf8");
172
+ const lines = stdoutBuffer.split("\n");
173
+ stdoutBuffer = lines.pop() ?? "";
174
+
175
+ for (const line of lines) {
176
+ const trimmed = line.trim();
177
+ if (!trimmed.startsWith(progressPrefix)) continue;
178
+ const payload = trimmed.slice(progressPrefix.length);
179
+ try {
180
+ const progress = JSON.parse(payload);
181
+ if (progress && typeof progress === "object") {
182
+ const nextProgress = {
183
+ total_scenes: Number(progress.total_scenes ?? 0),
184
+ processed_scenes: Number(progress.processed_scenes ?? 0),
185
+ scenes_changed: Number(progress.scenes_changed ?? 0),
186
+ failed_scenes: Number(progress.failed_scenes ?? 0),
187
+ };
188
+ job.progress = nextProgress;
189
+ }
190
+ } catch {
191
+ // Ignore malformed progress lines; they are best-effort telemetry.
192
+ }
193
+ }
167
194
  });
168
195
  child.stderr.on("data", () => {
169
196
  // avoid crashing on stderr backpressure for noisy runs
@@ -187,12 +214,32 @@ function startAsyncJob({ kind, requestPayload, onComplete }) {
187
214
  const payload = readJsonIfExists(resultPath);
188
215
  const successful = payload?.ok === true;
189
216
  const cancelledBySignal = signal === "SIGTERM" || signal === "SIGKILL";
217
+ const cancelledByPayload = payload?.cancelled === true;
190
218
 
191
219
  job.finishedAt = new Date().toISOString();
192
220
  job.result = payload;
193
221
 
222
+ const hasProgressFields = payload && (
223
+ payload.total_scenes !== undefined
224
+ || payload.processed_scenes !== undefined
225
+ || payload.scenes_changed !== undefined
226
+ || payload.failed_scenes !== undefined
227
+ );
228
+
229
+ if (payload && payload.ok === true && hasProgressFields) {
230
+ job.progress = {
231
+ total_scenes: Number(payload.total_scenes ?? job.progress?.total_scenes ?? 0),
232
+ processed_scenes: Number(payload.processed_scenes ?? job.progress?.processed_scenes ?? 0),
233
+ scenes_changed: Number(payload.scenes_changed ?? job.progress?.scenes_changed ?? 0),
234
+ failed_scenes: Number(payload.failed_scenes ?? job.progress?.failed_scenes ?? 0),
235
+ };
236
+ }
237
+
194
238
  if (job.status === "cancelling") {
195
- if (successful && !cancelledBySignal) {
239
+ if (cancelledByPayload) {
240
+ job.status = "cancelled";
241
+ job.error = "Async job cancelled after returning partial results.";
242
+ } else if (successful && !cancelledBySignal) {
196
243
  // Race: cancellation was requested as work completed successfully.
197
244
  job.status = "completed";
198
245
  } else {
@@ -379,6 +426,63 @@ function resolveWorldEntityDir({ kind, projectId, universeId, name }) {
379
426
  };
380
427
  }
381
428
 
429
+ function resolveBatchTargetScenes(dbHandle, {
430
+ projectId,
431
+ sceneIds,
432
+ part,
433
+ chapter,
434
+ onlyStale,
435
+ }) {
436
+ const projectExists = Boolean(
437
+ dbHandle.prepare(`SELECT 1 FROM projects WHERE project_id = ? LIMIT 1`).get(projectId)
438
+ );
439
+
440
+ if (sceneIds?.length) {
441
+ const placeholders = sceneIds.map(() => "?").join(",");
442
+ const existingRows = dbHandle.prepare(
443
+ `SELECT scene_id FROM scenes WHERE project_id = ? AND scene_id IN (${placeholders})`
444
+ ).all(projectId, ...sceneIds);
445
+ const existing = new Set(existingRows.map(row => row.scene_id));
446
+ const missing = sceneIds.filter(sceneId => !existing.has(sceneId));
447
+ if (missing.length > 0) {
448
+ return { ok: false, code: "NOT_FOUND", message: `Requested scene IDs were not found in project '${projectId}'.`, details: { missing_scene_ids: missing, project_id: projectId } };
449
+ }
450
+ }
451
+
452
+ const conditions = ["project_id = ?"];
453
+ const params = [projectId];
454
+
455
+ if (sceneIds?.length) {
456
+ const placeholders = sceneIds.map(() => "?").join(",");
457
+ conditions.push(`scene_id IN (${placeholders})`);
458
+ params.push(...sceneIds);
459
+ }
460
+ if (part !== undefined) {
461
+ conditions.push("part = ?");
462
+ params.push(part);
463
+ }
464
+ if (chapter !== undefined) {
465
+ conditions.push("chapter = ?");
466
+ params.push(chapter);
467
+ }
468
+ if (onlyStale) {
469
+ conditions.push("metadata_stale = 1");
470
+ }
471
+
472
+ const query = `
473
+ SELECT scene_id, project_id, file_path
474
+ FROM scenes
475
+ WHERE ${conditions.join(" AND ")}
476
+ ORDER BY part, chapter, timeline_position
477
+ `;
478
+
479
+ return {
480
+ ok: true,
481
+ rows: dbHandle.prepare(query).all(...params),
482
+ project_exists: projectExists,
483
+ };
484
+ }
485
+
382
486
  function createCanonicalWorldEntity({ kind, name, notes, projectId, universeId, meta }) {
383
487
  const prefix = kind === "character" ? "char" : "place";
384
488
  const idKey = kind === "character" ? "character_id" : "place_id";
@@ -671,7 +775,7 @@ function createMcpServer() {
671
775
  // ---- import_scrivener_sync ----------------------------------------------
672
776
  s.tool(
673
777
  "import_scrivener_sync",
674
- "Import Scrivener External Folder Sync Draft files into this server's WRITING_SYNC_DIR by generating scene sidecars and reconciling by Scrivener binder ID. Use this for first-time setup before sync().",
778
+ "[STABLE] Import Scrivener External Folder Sync Draft files into this server's WRITING_SYNC_DIR by generating scene sidecars and reconciling by Scrivener binder ID. This is the recommended default path for first-time setup before sync().",
675
779
  {
676
780
  source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
677
781
  project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb'). Defaults to a slug derived from WRITING_SYNC_DIR."),
@@ -793,15 +897,16 @@ function createMcpServer() {
793
897
  // ---- merge_scrivener_project_beta --------------------------------------
794
898
  s.tool(
795
899
  "merge_scrivener_project_beta",
796
- "[BETA] Merge metadata directly from a Scrivener .scriv project into existing scene sidecars. This path is opt-in and may be sensitive to Scrivener internal format changes. Requires scenes sidecars to already exist (for example, from import_scrivener_sync).",
900
+ "[BETA] Merge metadata directly from a Scrivener .scriv project into existing scene sidecars. This path is opt-in, requires sidecars to already exist (for example, from import_scrivener_sync), and may be sensitive to Scrivener internal format changes.",
797
901
  {
798
902
  source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
799
903
  project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb'). Defaults to a slug derived from WRITING_SYNC_DIR."),
800
904
  scenes_dir: z.string().optional().describe("Absolute path to the scenes directory containing .meta.yaml sidecars. Overrides the path derived from project_id. Use this for non-standard sync layouts."),
801
905
  dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
802
906
  auto_sync: z.boolean().optional().describe("If true (default), runs sync() after a non-dry-run merge."),
907
+ organize_by_chapters: z.boolean().optional().describe("If true (default false), relocate scene files into chapter-based folder hierarchies (e.g., chapter-7-harbor/). Chapter metadata is always extracted to sidecars regardless of this flag."),
803
908
  },
804
- async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = true }) => {
909
+ async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = true, organize_by_chapters = false }) => {
805
910
  if (project_id !== undefined) {
806
911
  const projectIdCheck = validateProjectId(project_id);
807
912
  if (!projectIdCheck.ok) {
@@ -839,6 +944,7 @@ function createMcpServer() {
839
944
  projectId: project_id,
840
945
  scenesDir: normalizedScenesDir,
841
946
  dryRun: Boolean(dry_run),
947
+ organizeByChapters: Boolean(organize_by_chapters),
842
948
  });
843
949
  } catch (error) {
844
950
  return errorResponse(
@@ -869,10 +975,14 @@ function createMcpServer() {
869
975
  dry_run: mergeResult.dryRun,
870
976
  sidecar_files: mergeResult.sidecarFiles,
871
977
  updated: mergeResult.updated,
978
+ relocated: mergeResult.relocated,
872
979
  unchanged: mergeResult.unchanged,
873
980
  no_data: mergeResult.noData,
874
981
  field_add_counts: mergeResult.fieldAddCounts,
875
982
  preview_changes: mergeResult.previewChanges,
983
+ warnings: mergeResult.warnings,
984
+ warnings_truncated: mergeResult.warningsTruncated,
985
+ warning_summary: mergeResult.warningSummary,
876
986
  stats: {
877
987
  sync_map_entries: mergeResult.stats.syncMapEntries,
878
988
  keyword_map_entries: mergeResult.stats.keywordMapEntries,
@@ -905,7 +1015,7 @@ function createMcpServer() {
905
1015
  // ---- async import/merge jobs --------------------------------------------
906
1016
  s.tool(
907
1017
  "import_scrivener_sync_async",
908
- "Start an asynchronous Scrivener External Folder Sync import job. Returns immediately with a job_id to poll via get_async_job_status.",
1018
+ "[STABLE] Start an asynchronous Scrivener External Folder Sync import job. This is the recommended default import path when the sync tree is large. Returns immediately with a job_id to poll via get_async_job_status.",
909
1019
  {
910
1020
  source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
911
1021
  project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
@@ -985,15 +1095,16 @@ function createMcpServer() {
985
1095
 
986
1096
  s.tool(
987
1097
  "merge_scrivener_project_beta_async",
988
- "Start an asynchronous beta Scrivener metadata merge job. Returns immediately with a job_id to poll via get_async_job_status.",
1098
+ "[BETA] Start an asynchronous Scrivener metadata merge job from a `.scriv` project into existing scene sidecars. Use this only after the stable import path has created sidecars. Returns immediately with a job_id to poll via get_async_job_status.",
989
1099
  {
990
1100
  source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
991
1101
  project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
992
1102
  scenes_dir: z.string().optional().describe("Absolute path to the scenes directory containing .meta.yaml sidecars. Overrides the path derived from project_id."),
993
1103
  dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
994
1104
  auto_sync: z.boolean().optional().describe("If true, runs sync() after a non-dry-run async merge finishes."),
1105
+ organize_by_chapters: z.boolean().optional().describe("If true (default false), relocate scene files into chapter-based folder hierarchies. Chapter metadata is always extracted to sidecars."),
995
1106
  },
996
- async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = false }) => {
1107
+ async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = false, organize_by_chapters = false }) => {
997
1108
  if (project_id !== undefined) {
998
1109
  const projectIdCheck = validateProjectId(project_id);
999
1110
  if (!projectIdCheck.ok) {
@@ -1032,6 +1143,7 @@ function createMcpServer() {
1032
1143
  project_id,
1033
1144
  scenes_dir: normalizedScenesDir,
1034
1145
  dry_run: Boolean(dry_run),
1146
+ organize_by_chapters: Boolean(organize_by_chapters),
1035
1147
  },
1036
1148
  context: {
1037
1149
  sync_dir: SYNC_DIR,
@@ -1062,9 +1174,129 @@ function createMcpServer() {
1062
1174
  }
1063
1175
  );
1064
1176
 
1177
+ s.tool(
1178
+ "enrich_scene_characters_batch",
1179
+ "Start an asynchronous batch job that infers scene character mentions and updates scene metadata links. Version 1 uses canonical character names only (no aliases). Defaults to dry_run=true.",
1180
+ {
1181
+ project_id: z.string().describe("Project ID (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
1182
+ scene_ids: z.array(z.string()).optional().describe("Optional allowlist of scene IDs to process before other filters are applied."),
1183
+ part: z.number().int().optional().describe("Optional part number filter."),
1184
+ chapter: z.number().int().optional().describe("Optional chapter number filter."),
1185
+ only_stale: z.boolean().optional().describe("If true, only process scenes currently marked metadata_stale."),
1186
+ dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing sidecars."),
1187
+ replace_mode: z.enum(["merge", "replace"]).optional().describe("merge (default): add inferred IDs; replace: overwrite characters with inferred IDs."),
1188
+ max_scenes: z.number().int().positive().optional().describe("Hard guardrail for resolved scene count (default: 200)."),
1189
+ include_match_details: z.boolean().optional().describe("If true, include extra match diagnostics per scene."),
1190
+ confirm_replace: z.boolean().optional().describe("Must be true when replace_mode=replace."),
1191
+ },
1192
+ async ({
1193
+ project_id,
1194
+ scene_ids,
1195
+ part,
1196
+ chapter,
1197
+ only_stale = false,
1198
+ dry_run = true,
1199
+ replace_mode = "merge",
1200
+ max_scenes = 200,
1201
+ include_match_details = false,
1202
+ confirm_replace = false,
1203
+ }) => {
1204
+ const projectIdCheck = validateProjectId(project_id);
1205
+ if (!projectIdCheck.ok) {
1206
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1207
+ }
1208
+
1209
+ if (replace_mode === "replace" && !confirm_replace) {
1210
+ return errorResponse(
1211
+ "VALIDATION_ERROR",
1212
+ "replace_mode=replace requires confirm_replace=true.",
1213
+ { replace_mode, confirm_replace }
1214
+ );
1215
+ }
1216
+
1217
+ if (!dry_run && !SYNC_DIR_WRITABLE) {
1218
+ return errorResponse(
1219
+ "READ_ONLY",
1220
+ "Cannot run batch character enrichment in write mode: sync dir is read-only.",
1221
+ { sync_dir: SYNC_DIR_ABS }
1222
+ );
1223
+ }
1224
+
1225
+ const characterRows = db.prepare(`
1226
+ SELECT character_id, name
1227
+ FROM characters
1228
+ WHERE project_id = ? OR universe_id = (SELECT universe_id FROM projects WHERE project_id = ?)
1229
+ ORDER BY length(name) DESC
1230
+ `).all(project_id, project_id);
1231
+
1232
+ const targetResolution = resolveBatchTargetScenes(db, {
1233
+ projectId: project_id,
1234
+ sceneIds: scene_ids,
1235
+ part,
1236
+ chapter,
1237
+ onlyStale: Boolean(only_stale),
1238
+ });
1239
+ if (!targetResolution.ok) {
1240
+ return errorResponse(targetResolution.code, targetResolution.message, targetResolution.details);
1241
+ }
1242
+
1243
+ const targetScenes = targetResolution.rows;
1244
+ const projectExists = targetResolution.project_exists !== false;
1245
+ if (targetScenes.length > max_scenes) {
1246
+ return errorResponse(
1247
+ "VALIDATION_ERROR",
1248
+ `Matched ${targetScenes.length} scenes, which exceeds max_scenes=${max_scenes}.`,
1249
+ {
1250
+ matched_scenes: targetScenes.length,
1251
+ max_scenes,
1252
+ project_id,
1253
+ }
1254
+ );
1255
+ }
1256
+
1257
+ const job = startAsyncJob({
1258
+ kind: "enrich_scene_characters_batch",
1259
+ requestPayload: {
1260
+ kind: "enrich_scene_characters_batch",
1261
+ args: {
1262
+ project_id,
1263
+ dry_run: Boolean(dry_run),
1264
+ replace_mode,
1265
+ include_match_details: Boolean(include_match_details),
1266
+ project_exists: projectExists,
1267
+ target_scenes: targetScenes,
1268
+ character_rows: characterRows,
1269
+ },
1270
+ context: { sync_dir: SYNC_DIR },
1271
+ },
1272
+ onComplete: (completedJob) => {
1273
+ if (dry_run || completedJob.status !== "completed" || !completedJob.result?.ok) return;
1274
+
1275
+ syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
1276
+
1277
+ const changedScenes = (completedJob.result.results ?? [])
1278
+ .filter(row => row.status === "changed")
1279
+ .map(row => row.scene_id);
1280
+
1281
+ for (const sceneId of changedScenes) {
1282
+ db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
1283
+ .run(sceneId, project_id);
1284
+ }
1285
+ },
1286
+ });
1287
+
1288
+ return jsonResponse({
1289
+ ok: true,
1290
+ async: true,
1291
+ job: toPublicJob(job, false),
1292
+ next_step: "Call get_async_job_status with job_id until status is 'completed', 'failed', or 'cancelled'.",
1293
+ });
1294
+ }
1295
+ );
1296
+
1065
1297
  s.tool(
1066
1298
  "get_async_job_status",
1067
- "Get status and result for an asynchronous job started by import_scrivener_sync_async or merge_scrivener_project_beta_async.",
1299
+ "Get status and result for an asynchronous job started by async tools such as import_scrivener_sync_async, merge_scrivener_project_beta_async, or enrich_scene_characters_batch.",
1068
1300
  {
1069
1301
  job_id: z.string().describe("Job ID returned by an async start tool."),
1070
1302
  include_result: z.boolean().optional().describe("If true (default), includes completed result payload when available."),
@@ -1081,7 +1313,7 @@ function createMcpServer() {
1081
1313
 
1082
1314
  s.tool(
1083
1315
  "list_async_jobs",
1084
- "List asynchronous import/merge jobs currently known to this server.",
1316
+ "List asynchronous jobs currently known to this server.",
1085
1317
  {
1086
1318
  include_results: z.boolean().optional().describe("If true, includes completed result payloads."),
1087
1319
  },
@@ -1199,7 +1431,7 @@ function createMcpServer() {
1199
1431
  },
1200
1432
  async ({ project_id, character, beat, tag, part, chapter, pov, page, page_size }) => {
1201
1433
  let query = `
1202
- SELECT DISTINCT s.scene_id, s.project_id, s.title, s.part, s.chapter, s.pov,
1434
+ SELECT DISTINCT s.scene_id, s.project_id, s.title, s.part, s.chapter, s.chapter_title, s.pov,
1203
1435
  s.logline, s.scene_change, s.causality, s.stakes, s.scene_functions,
1204
1436
  s.save_the_cat_beat, s.timeline_position, s.story_time,
1205
1437
  s.word_count, s.metadata_stale
@@ -1357,7 +1589,7 @@ function createMcpServer() {
1357
1589
  },
1358
1590
  async ({ character_id, project_id, page, page_size }) => {
1359
1591
  let query = `
1360
- SELECT s.scene_id, s.project_id, s.part, s.chapter, s.title, s.logline,
1592
+ SELECT s.scene_id, s.project_id, s.part, s.chapter, s.chapter_title, s.title, s.logline,
1361
1593
  s.scene_change, s.causality, s.stakes, s.scene_functions,
1362
1594
  s.save_the_cat_beat, s.timeline_position, s.story_time, s.pov, s.metadata_stale
1363
1595
  FROM scenes s
@@ -1635,7 +1867,7 @@ function createMcpServer() {
1635
1867
 
1636
1868
  if (!shouldPaginate) {
1637
1869
  const rows = db.prepare(`
1638
- SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.metadata_stale
1870
+ SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
1639
1871
  FROM scenes_fts f
1640
1872
  JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
1641
1873
  WHERE scenes_fts MATCH ?
@@ -1652,7 +1884,7 @@ function createMcpServer() {
1652
1884
  const offset = (normalizedPage - 1) * safePageSize;
1653
1885
 
1654
1886
  const rows = db.prepare(`
1655
- SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.metadata_stale
1887
+ SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
1656
1888
  FROM scenes_fts f
1657
1889
  JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
1658
1890
  WHERE scenes_fts MATCH ?
@@ -1718,7 +1950,7 @@ function createMcpServer() {
1718
1950
  }
1719
1951
 
1720
1952
  const rows = db.prepare(`
1721
- SELECT s.scene_id, s.project_id, s.part, s.chapter, s.title, s.logline,
1953
+ SELECT s.scene_id, s.project_id, s.part, s.chapter, s.chapter_title, s.title, s.logline,
1722
1954
  st.beat AS thread_beat, s.timeline_position, s.story_time, s.metadata_stale
1723
1955
  FROM scenes s
1724
1956
  JOIN scene_threads st ON st.scene_id = s.scene_id AND st.thread_id = ?
@@ -2059,7 +2291,7 @@ function createMcpServer() {
2059
2291
  let query = `
2060
2292
  SELECT r.from_character, r.to_character, r.relationship_type, r.strength,
2061
2293
  r.scene_id, r.note,
2062
- s.part, s.chapter, s.timeline_position, s.title AS scene_title
2294
+ s.part, s.chapter, s.chapter_title, s.timeline_position, s.title AS scene_title
2063
2295
  FROM character_relationships r
2064
2296
  LEFT JOIN scenes s ON s.scene_id = r.scene_id
2065
2297
  WHERE r.from_character = ? AND r.to_character = ?
package/metadata-lint.js CHANGED
@@ -32,6 +32,7 @@ const sceneSchema = z.object({
32
32
  title: z.string().min(1).optional(),
33
33
  part: z.number().int().positive().optional(),
34
34
  chapter: z.number().int().positive().optional(),
35
+ chapter_title: z.string().min(1).optional(),
35
36
  pov: z.string().min(1).optional(),
36
37
  logline: z.string().min(1).optional(),
37
38
  save_the_cat_beat: z.string().min(1).optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -2,12 +2,24 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { importScrivenerSync } from "../importer.js";
4
4
  import { mergeScrivenerProjectMetadata } from "../scrivener-direct.js";
5
+ import { runSceneCharacterBatch } from "../scene-character-batch.js";
6
+ import { ASYNC_PROGRESS_PREFIX } from "../async-progress.js";
7
+
8
+ const PROGRESS_PREFIX = ASYNC_PROGRESS_PREFIX;
5
9
 
6
10
  function writeResult(resultPath, payload) {
7
11
  fs.mkdirSync(path.dirname(resultPath), { recursive: true });
8
12
  fs.writeFileSync(resultPath, JSON.stringify(payload, null, 2), "utf8");
9
13
  }
10
14
 
15
+ function writeProgress(payload) {
16
+ try {
17
+ process.stdout.write(`${PROGRESS_PREFIX}${JSON.stringify(payload)}\n`);
18
+ } catch {
19
+ // Best-effort only; never fail the job due to progress telemetry.
20
+ }
21
+ }
22
+
11
23
  function normalizeImportResult(importResult) {
12
24
  const importPayload = {
13
25
  source_dir: importResult.scrivenerDir,
@@ -49,10 +61,14 @@ function normalizeMergeResult(mergeResult) {
49
61
  dry_run: mergeResult.dryRun,
50
62
  sidecar_files: mergeResult.sidecarFiles,
51
63
  updated: mergeResult.updated,
64
+ relocated: mergeResult.relocated,
52
65
  unchanged: mergeResult.unchanged,
53
66
  no_data: mergeResult.noData,
54
67
  field_add_counts: mergeResult.fieldAddCounts,
55
68
  preview_changes: mergeResult.previewChanges,
69
+ warnings: mergeResult.warnings,
70
+ warnings_truncated: mergeResult.warningsTruncated,
71
+ warning_summary: mergeResult.warningSummary,
56
72
  stats: {
57
73
  sync_map_entries: mergeResult.stats.syncMapEntries,
58
74
  keyword_map_entries: mergeResult.stats.keywordMapEntries,
@@ -68,7 +84,14 @@ function normalizeMergeResult(mergeResult) {
68
84
  };
69
85
  }
70
86
 
71
- function main() {
87
+ function normalizeSceneCharacterBatchResult(batchResult) {
88
+ return {
89
+ ok: true,
90
+ ...batchResult,
91
+ };
92
+ }
93
+
94
+ async function main() {
72
95
  const requestPath = process.argv[2];
73
96
  const resultPath = process.argv[3];
74
97
 
@@ -102,16 +125,43 @@ function main() {
102
125
  projectId: request.args?.project_id,
103
126
  scenesDir: request.args?.scenes_dir,
104
127
  dryRun: Boolean(request.args?.dry_run),
128
+ organizeByChapters: Boolean(request.args?.organize_by_chapters),
105
129
  });
106
130
  writeResult(resultPath, normalizeMergeResult(result));
107
131
  return;
108
132
  }
109
133
 
134
+ if (request.kind === "enrich_scene_characters_batch") {
135
+ let cancellationRequested = false;
136
+ const handleSigterm = () => {
137
+ cancellationRequested = true;
138
+ };
139
+ process.on("SIGTERM", handleSigterm);
140
+
141
+ const result = await runSceneCharacterBatch({
142
+ syncDir,
143
+ args: {
144
+ project_id: request.args?.project_id,
145
+ dry_run: Boolean(request.args?.dry_run),
146
+ replace_mode: request.args?.replace_mode ?? "merge",
147
+ include_match_details: Boolean(request.args?.include_match_details),
148
+ project_exists: request.args?.project_exists !== false,
149
+ target_scenes: request.args?.target_scenes ?? [],
150
+ character_rows: request.args?.character_rows ?? [],
151
+ },
152
+ onProgress: progress => writeProgress({ kind: request.kind, ...progress }),
153
+ shouldCancel: () => cancellationRequested,
154
+ });
155
+ process.off("SIGTERM", handleSigterm);
156
+ writeResult(resultPath, normalizeSceneCharacterBatchResult(result));
157
+ return;
158
+ }
159
+
110
160
  throw new Error(`Unsupported async job kind '${request.kind}'.`);
111
161
  }
112
162
 
113
163
  try {
114
- main();
164
+ await main();
115
165
  } catch (error) {
116
166
  const resultPath = process.argv[3];
117
167
  if (resultPath) {
@@ -425,9 +425,9 @@ function generateMarkdown(tools) {
425
425
  lines.push('_No parameters._');
426
426
  } else {
427
427
  lines.push('| Parameter | Type | Required | Description |');
428
- lines.push('|-----------|------|:--------:|-------------|');
428
+ lines.push('| --- | --- | :---: | --- |');
429
429
  for (const p of tool.params) {
430
- const req = p.optional ? '' : '';
430
+ const req = p.optional ? 'No' : 'Yes';
431
431
  const desc = p.description.replace(/\|/g, '\\|'); // escape pipes
432
432
  lines.push(`| \`${p.name}\` | \`${p.type}\` | ${req} | ${desc} |`);
433
433
  }
@@ -11,6 +11,7 @@
11
11
  * Options:
12
12
  * --project <id> Project ID (default: derived from mcp-sync-dir name)
13
13
  * --dry-run Show what would change without writing anything
14
+ * --organize-by-chapters Relocate scene files into part/chapter folders
14
15
  *
15
16
  * What it merges into scene sidecars:
16
17
  * synopsis - from Files/Data/<UUID>/synopsis.txt
@@ -34,13 +35,14 @@ import { mergeScrivenerProjectMetadata } from "../scrivener-direct.js";
34
35
  // ---------------------------------------------------------------------------
35
36
  const args = process.argv.slice(2);
36
37
  if (args.length < 2 || args[0] === "--help") {
37
- console.log("Usage: node scripts/merge-scrivx.js <path-to.scriv> <mcp-sync-dir> [--project <id>] [--dry-run]");
38
+ console.log("Usage: node scripts/merge-scrivx.js <path-to.scriv> <mcp-sync-dir> [--project <id>] [--dry-run] [--organize-by-chapters]");
38
39
  process.exit(args[0] === "--help" ? 0 : 1);
39
40
  }
40
41
 
41
42
  const scrivPath = path.resolve(args[0]);
42
43
  const mcpSyncDir = path.resolve(args[1]);
43
44
  const dryRun = args.includes("--dry-run");
45
+ const organizeByChapters = args.includes("--organize-by-chapters");
44
46
  const projectIdx = args.indexOf("--project");
45
47
  const projectId = projectIdx !== -1
46
48
  ? args[projectIdx + 1]
@@ -58,6 +60,7 @@ try {
58
60
  mcpSyncDir,
59
61
  projectId,
60
62
  dryRun,
63
+ organizeByChapters,
61
64
  logger: line => console.log(line),
62
65
  });
63
66
  } catch (err) {
@@ -23,6 +23,9 @@ function children(el, tag) {
23
23
 
24
24
  function walkYamls(dir, list = []) {
25
25
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
26
+ if (entry.isDirectory() && /^(projects|universes)$/i.test(entry.name)) {
27
+ continue;
28
+ }
26
29
  const full = path.join(dir, entry.name);
27
30
  if (entry.isDirectory()) walkYamls(full, list);
28
31
  else if (entry.name.endsWith(".meta.yaml")) list.push(full);
@@ -34,29 +37,170 @@ function isPlainObject(value) {
34
37
  return value !== null && typeof value === "object" && !Array.isArray(value);
35
38
  }
36
39
 
40
+ function slugifyPathSegment(value) {
41
+ return String(value ?? "")
42
+ .normalize("NFKD")
43
+ .replace(/[\u0300-\u036f]/g, "")
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, "-")
46
+ .replace(/^-+|-+$/g, "")
47
+ .slice(0, 50);
48
+ }
49
+
50
+ function chapterFolderName(chapter, chapterTitle) {
51
+ if (chapter === null || chapter === undefined) return null;
52
+ const suffix = slugifyPathSegment(chapterTitle);
53
+ return suffix ? `chapter-${chapter}-${suffix}` : `chapter-${chapter}`;
54
+ }
55
+
56
+ function sceneContainerDir(scenesDir, part, chapter, chapterTitle, organizeByChapters = true) {
57
+ const segments = [scenesDir];
58
+ if (!organizeByChapters) {
59
+ return path.join(...segments);
60
+ }
61
+ if (part !== null && part !== undefined) segments.push(`part-${part}`);
62
+ const chapterDir = chapterFolderName(chapter, chapterTitle);
63
+ if (chapterDir) segments.push(chapterDir);
64
+ return path.join(...segments);
65
+ }
66
+
67
+ function findProsePathForSidecar(sidecarPath) {
68
+ const proseCandidates = [
69
+ sidecarPath.replace(/\.meta\.yaml$/, ".md"),
70
+ sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
71
+ ];
72
+ return proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
73
+ }
74
+
75
+ function moveFileIfNeeded(fromPath, toPath) {
76
+ if (!fromPath || fromPath === toPath) return;
77
+ fs.mkdirSync(path.dirname(toPath), { recursive: true });
78
+ if (fs.existsSync(toPath)) {
79
+ return {
80
+ moved: false,
81
+ warning: {
82
+ code: "relocate_destination_exists",
83
+ message: "Skipped moving prose file because destination already exists.",
84
+ from_path: fromPath,
85
+ to_path: toPath,
86
+ },
87
+ };
88
+ }
89
+
90
+ try {
91
+ fs.renameSync(fromPath, toPath);
92
+ } catch (error) {
93
+ if (!error || typeof error !== "object" || error.code !== "EXDEV") {
94
+ throw error;
95
+ }
96
+ fs.copyFileSync(fromPath, toPath);
97
+ fs.unlinkSync(fromPath);
98
+ }
99
+
100
+ return { moved: true };
101
+ }
102
+
103
+ const KNOWN_CUSTOM_FIELD_IDS = new Set([
104
+ "savethecat!",
105
+ "causality",
106
+ "stakes",
107
+ "change",
108
+ "f:character",
109
+ "f:mood",
110
+ "f:theme",
111
+ ]);
112
+
113
+ const MAX_RETURNED_WARNINGS = 25;
114
+
115
+ function recordWarning(summary, warning) {
116
+ if (!summary[warning.code]) {
117
+ summary[warning.code] = { count: 0, examples: [] };
118
+ }
119
+
120
+ const entry = summary[warning.code];
121
+ entry.count++;
122
+
123
+ if (entry.examples.length < 5) {
124
+ const example = { message: warning.message };
125
+ for (const key of ["file", "sync_number", "field_id", "value", "uuid", "from_path", "to_path", "moved_to"]) {
126
+ if (warning[key] !== undefined && warning[key] !== null) {
127
+ example[key] = warning[key];
128
+ }
129
+ }
130
+ entry.examples.push(example);
131
+ }
132
+ }
133
+
134
+ function pushWarning(warnings, warningSummary, warning) {
135
+ recordWarning(warningSummary, warning);
136
+
137
+ if (warnings.length < MAX_RETURNED_WARNINGS) {
138
+ warnings.push(warning);
139
+ return false;
140
+ }
141
+
142
+ return true;
143
+ }
144
+
37
145
  function buildMergeDataFromProject(projectData, uuid) {
38
- const { metaByUUID, partByUUID, chapterByUUID } = projectData;
39
- const { customFields, characters, versions, synopsis } = metaByUUID[uuid] ?? {};
146
+ const { metaByUUID, partByUUID, chapterByUUID, chapterTitleByUUID } = projectData;
147
+ const { customFields, tags, synopsis } = metaByUUID[uuid] ?? {};
40
148
  const part = partByUUID[uuid] ?? null;
41
149
  const chapter = chapterByUUID[uuid] ?? null;
150
+ const chapterTitle = chapterTitleByUUID[uuid] ?? null;
151
+ const warnings = [];
42
152
 
43
- if (!customFields && !characters && !versions && !synopsis && part === null && chapter === null) return null;
153
+ if (!customFields && !tags && !synopsis && part === null && chapter === null && !chapterTitle) {
154
+ return { mergeData: null, warnings };
155
+ }
44
156
 
45
157
  const out = {};
46
158
 
47
159
  if (part !== null) out.part = part;
48
160
  if (chapter !== null) out.chapter = chapter;
161
+ if (chapterTitle) out.chapter_title = chapterTitle;
49
162
  if (synopsis) out.synopsis = synopsis;
50
- if (characters?.length) out.characters = characters;
51
- if (versions?.length) out.versions = versions;
163
+ if (tags?.length) out.tags = tags;
164
+
165
+ for (const [fieldId, value] of Object.entries(customFields ?? {})) {
166
+ if (!KNOWN_CUSTOM_FIELD_IDS.has(fieldId) && String(value ?? "").trim()) {
167
+ warnings.push({
168
+ code: "ignored_custom_field",
169
+ message: `Ignored unsupported Scrivener custom field '${fieldId}'.`,
170
+ field_id: fieldId,
171
+ value: String(value),
172
+ uuid,
173
+ });
174
+ }
175
+ }
52
176
 
53
177
  const stcBeat = customFields?.["savethecat!"];
54
178
  if (stcBeat && typeof stcBeat === "string" && stcBeat.trim()) {
55
179
  out.save_the_cat_beat = stcBeat.trim();
56
180
  }
57
181
 
58
- const causality = Number(customFields?.["causality"] ?? 0);
59
- const stakes = Number(customFields?.["stakes"] ?? 0);
182
+ const causalityRaw = customFields?.["causality"];
183
+ const stakesRaw = customFields?.["stakes"];
184
+ const causality = Number(causalityRaw ?? 0);
185
+ const stakes = Number(stakesRaw ?? 0);
186
+ if (causalityRaw !== undefined && String(causalityRaw).trim() && Number.isNaN(causality)) {
187
+ warnings.push({
188
+ code: "invalid_custom_field_value",
189
+ message: "Ignored non-numeric Scrivener custom field value for 'causality'.",
190
+ field_id: "causality",
191
+ value: String(causalityRaw),
192
+ uuid,
193
+ });
194
+ }
195
+ if (stakesRaw !== undefined && String(stakesRaw).trim() && Number.isNaN(stakes)) {
196
+ warnings.push({
197
+ code: "invalid_custom_field_value",
198
+ message: "Ignored non-numeric Scrivener custom field value for 'stakes'.",
199
+ field_id: "stakes",
200
+ value: String(stakesRaw),
201
+ uuid,
202
+ });
203
+ }
60
204
  if (causality) out.causality = causality;
61
205
  if (stakes) out.stakes = stakes;
62
206
 
@@ -69,7 +213,10 @@ function buildMergeDataFromProject(projectData, uuid) {
69
213
  if (customFields?.["f:theme"] === "Yes" || customFields?.["f:theme"] === true) fnFlags.push("theme");
70
214
  if (fnFlags.length) out.scene_functions = fnFlags;
71
215
 
72
- return Object.keys(out).length ? out : null;
216
+ return {
217
+ mergeData: Object.keys(out).length ? out : null,
218
+ warnings,
219
+ };
73
220
  }
74
221
 
75
222
  export function mergeSidecarData(existing, mergeData) {
@@ -144,15 +291,13 @@ export function loadScrivenerProjectData(scrivPath) {
144
291
  }
145
292
  }
146
293
 
147
- const characters = [];
148
- const versions = [];
294
+ const tags = [];
149
295
  const kwEl = children(item, "Keywords")[0];
150
296
  if (kwEl) {
151
297
  for (const kwId of children(kwEl, "KeywordID")) {
152
298
  const name = keywordMap[text(kwId)];
153
299
  if (!name) continue;
154
- if (/^v\d[\d.a-z]*$/i.test(name)) versions.push(name);
155
- else characters.push(name);
300
+ tags.push(name);
156
301
  }
157
302
  }
158
303
 
@@ -163,11 +308,16 @@ export function loadScrivenerProjectData(scrivPath) {
163
308
  if (candidate) synopsis = candidate;
164
309
  }
165
310
 
166
- metaByUUID[uuid] = { customFields, characters, versions, synopsis };
311
+ metaByUUID[uuid] = {
312
+ customFields,
313
+ tags: [...new Set(tags)],
314
+ synopsis,
315
+ };
167
316
  }
168
317
 
169
318
  const partByUUID = {};
170
319
  const chapterByUUID = {};
320
+ const chapterTitleByUUID = {};
171
321
  let partNum = 0;
172
322
  let chapterNum = 0;
173
323
 
@@ -176,21 +326,29 @@ export function loadScrivenerProjectData(scrivPath) {
176
326
  const uuid = attr(child, "UUID");
177
327
  const type = attr(child, "Type");
178
328
  const childrenEl = children(child, "Children")[0];
329
+ const title = text(children(child, "Title")[0]);
179
330
 
180
331
  if (type === "Folder" && currentPart === null) {
181
332
  partNum++;
182
- if (childrenEl) walkHierarchy(childrenEl, partNum, null);
333
+ if (childrenEl) walkHierarchy(childrenEl, { number: partNum, title }, null);
183
334
  } else if (type === "Folder") {
184
335
  chapterNum++;
336
+ const nextChapter = { number: chapterNum, title };
185
337
  if (uuid) {
186
- partByUUID[uuid] = currentPart;
338
+ if (currentPart?.number !== null && currentPart?.number !== undefined) {
339
+ partByUUID[uuid] = currentPart.number;
340
+ }
187
341
  chapterByUUID[uuid] = chapterNum;
342
+ if (title) chapterTitleByUUID[uuid] = title;
188
343
  }
189
- if (childrenEl) walkHierarchy(childrenEl, currentPart, chapterNum);
344
+ if (childrenEl) walkHierarchy(childrenEl, currentPart, nextChapter);
190
345
  } else if (type === "Text") {
191
- if (uuid && currentChapter !== null) {
192
- partByUUID[uuid] = currentPart;
193
- chapterByUUID[uuid] = currentChapter;
346
+ if (uuid && currentChapter?.number !== null && currentChapter?.number !== undefined) {
347
+ if (currentPart?.number !== null && currentPart?.number !== undefined) {
348
+ partByUUID[uuid] = currentPart.number;
349
+ }
350
+ chapterByUUID[uuid] = currentChapter.number;
351
+ if (currentChapter.title) chapterTitleByUUID[uuid] = currentChapter.title;
194
352
  }
195
353
  }
196
354
  }
@@ -214,6 +372,7 @@ export function loadScrivenerProjectData(scrivPath) {
214
372
  metaByUUID,
215
373
  partByUUID,
216
374
  chapterByUUID,
375
+ chapterTitleByUUID,
217
376
  };
218
377
  }
219
378
 
@@ -223,6 +382,7 @@ export function mergeScrivenerProjectMetadata({
223
382
  projectId,
224
383
  scenesDir: scenesDirOverride,
225
384
  dryRun = false,
385
+ organizeByChapters = false,
226
386
  logger = () => {},
227
387
  }) {
228
388
  const mcpSyncDirAbs = path.resolve(mcpSyncDir);
@@ -268,15 +428,25 @@ export function mergeScrivenerProjectMetadata({
268
428
  let unchanged = 0;
269
429
  let noData = 0;
270
430
  let skippedNoBracketId = 0;
431
+ let relocated = 0;
271
432
  const fieldAddCounts = {};
272
433
  const previewChanges = [];
434
+ const warnings = [];
435
+ const warningSummary = {};
436
+ let warningsTruncated = false;
273
437
 
274
438
  for (const sidecarPath of sidecarFiles) {
275
439
  const filename = path.basename(sidecarPath);
440
+ const prosePath = findProsePathForSidecar(sidecarPath);
276
441
  const match = filename.match(/\[(\d+)\]\.meta\.yaml$/);
277
442
  if (!match) {
278
443
  logger(` SKIP (no bracket ID) ${filename}`);
279
444
  skippedNoBracketId++;
445
+ warningsTruncated = pushWarning(warnings, warningSummary, {
446
+ code: "missing_bracket_id",
447
+ message: "Skipped sidecar because filename does not include a Scrivener sync number in brackets.",
448
+ file: filename,
449
+ }) || warningsTruncated;
280
450
  continue;
281
451
  }
282
452
 
@@ -285,10 +455,20 @@ export function mergeScrivenerProjectMetadata({
285
455
  if (!uuid) {
286
456
  logger(` SKIP (no UUID for [${syncNum}]) ${filename}`);
287
457
  noData++;
458
+ warningsTruncated = pushWarning(warnings, warningSummary, {
459
+ code: "missing_uuid_mapping",
460
+ message: `Skipped sidecar because Scrivener sync number [${syncNum}] has no UUID mapping in the project.`,
461
+ file: filename,
462
+ sync_number: syncNum,
463
+ }) || warningsTruncated;
288
464
  continue;
289
465
  }
290
466
 
291
- const mergeData = buildMergeDataFromProject(projectData, uuid);
467
+ const { mergeData, warnings: mergeWarnings } = buildMergeDataFromProject(projectData, uuid);
468
+ for (const warning of mergeWarnings) {
469
+ warningsTruncated = pushWarning(warnings, warningSummary, { ...warning, file: filename }) || warningsTruncated;
470
+ }
471
+
292
472
  if (!mergeData) {
293
473
  unchanged++;
294
474
  continue;
@@ -300,8 +480,22 @@ export function mergeScrivenerProjectMetadata({
300
480
  }
301
481
  const existing = existingRaw ?? {};
302
482
  const { merged, changed, newKeys } = mergeSidecarData(existing, mergeData);
303
-
304
- if (!changed) {
483
+ const effective = changed ? merged : existing;
484
+ const targetDir = sceneContainerDir(
485
+ scenesDir,
486
+ effective.part ?? null,
487
+ effective.chapter ?? null,
488
+ effective.chapter_title ?? null,
489
+ organizeByChapters,
490
+ );
491
+ const targetSidecarPath = organizeByChapters ? path.join(targetDir, filename) : sidecarPath;
492
+ const targetProsePath = prosePath
493
+ ? (organizeByChapters ? path.join(targetDir, path.basename(prosePath)) : prosePath)
494
+ : null;
495
+ const needsMove = path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
496
+ || (prosePath && targetProsePath && path.resolve(prosePath) !== path.resolve(targetProsePath));
497
+
498
+ if (!changed && !needsMove) {
305
499
  unchanged++;
306
500
  continue;
307
501
  }
@@ -311,23 +505,92 @@ export function mergeScrivenerProjectMetadata({
311
505
  }
312
506
 
313
507
  if (previewChanges.length < 25) {
314
- previewChanges.push({ file: filename, added_keys: [...newKeys] });
508
+ previewChanges.push({
509
+ file: filename,
510
+ added_keys: [...newKeys],
511
+ ...(needsMove ? { moved_to: path.relative(scenesDir, targetSidecarPath) || filename } : {}),
512
+ });
315
513
  }
316
514
 
515
+ let didRelocate = false;
516
+
317
517
  if (dryRun) {
318
518
  logger(` DRY ${filename}`);
319
519
  for (const key of newKeys) {
320
520
  logger(` + ${key}: ${JSON.stringify(mergeData[key]).slice(0, 80)}`);
321
521
  }
522
+ if (needsMove) {
523
+ logger(` -> ${path.relative(scenesDir, targetSidecarPath) || filename}`);
524
+ }
525
+ didRelocate = needsMove;
322
526
  } else {
323
- fs.writeFileSync(sidecarPath, yaml.dump(merged, { lineWidth: 120 }), "utf8");
324
- logger(` OK ${filename} [+${newKeys.join(", ")}]`);
527
+ let proseMoveWarning = null;
528
+ let shouldRelocateSidecar = organizeByChapters;
529
+
530
+ if (
531
+ shouldRelocateSidecar
532
+ && path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
533
+ && fs.existsSync(targetSidecarPath)
534
+ ) {
535
+ shouldRelocateSidecar = false;
536
+ warningsTruncated = pushWarning(
537
+ warnings,
538
+ warningSummary,
539
+ {
540
+ code: "relocate_sidecar_destination_exists",
541
+ message: "Skipped relocating sidecar because destination already exists.",
542
+ from_path: sidecarPath,
543
+ to_path: targetSidecarPath,
544
+ file: filename,
545
+ }
546
+ ) || warningsTruncated;
547
+ }
548
+
549
+ if (shouldRelocateSidecar && prosePath && targetProsePath) {
550
+ const moveResult = moveFileIfNeeded(prosePath, targetProsePath);
551
+ if (moveResult?.warning) {
552
+ proseMoveWarning = moveResult.warning;
553
+ shouldRelocateSidecar = false;
554
+ warningsTruncated = pushWarning(
555
+ warnings,
556
+ warningSummary,
557
+ {
558
+ ...moveResult.warning,
559
+ file: filename,
560
+ }
561
+ ) || warningsTruncated;
562
+ }
563
+ }
564
+
565
+ const finalSidecarPath = shouldRelocateSidecar ? targetSidecarPath : sidecarPath;
566
+ fs.mkdirSync(path.dirname(finalSidecarPath), { recursive: true });
567
+ fs.writeFileSync(finalSidecarPath, yaml.dump(effective, { lineWidth: 120 }), "utf8");
568
+ if (
569
+ shouldRelocateSidecar
570
+ && path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
571
+ && fs.existsSync(sidecarPath)
572
+ ) {
573
+ fs.unlinkSync(sidecarPath);
574
+ }
575
+
576
+ const changes = [];
577
+ if (newKeys.length) changes.push(`+${newKeys.join(", ")}`);
578
+ if (needsMove && shouldRelocateSidecar) {
579
+ changes.push(`moved to ${path.relative(scenesDir, targetSidecarPath) || filename}`);
580
+ }
581
+ if (proseMoveWarning) {
582
+ changes.push("sidecar kept in place (prose move skipped)");
583
+ }
584
+ logger(` OK ${filename}${changes.length ? ` [${changes.join("; ")}]` : ""}`);
585
+ didRelocate = needsMove && shouldRelocateSidecar;
325
586
  }
587
+ if (didRelocate) relocated++;
326
588
  updated++;
327
589
  }
328
590
 
329
591
  logger(`\n${"─".repeat(50)}`);
330
592
  logger(`Updated: ${updated} sidecars${dryRun ? " (dry run)" : ""}`);
593
+ if (relocated) logger(`Relocated: ${relocated} scene file pair(s)`);
331
594
  logger(`Unchanged: ${unchanged} (already complete or no new data)`);
332
595
  if (skippedNoBracketId) logger(`Skipped: ${skippedNoBracketId} (no bracket ID in filename)`);
333
596
  if (noData) logger(`No data: ${noData} (no matching binder entry)`);
@@ -340,11 +603,15 @@ export function mergeScrivenerProjectMetadata({
340
603
  dryRun: Boolean(dryRun),
341
604
  sidecarFiles: sidecarFiles.length,
342
605
  updated,
606
+ relocated,
343
607
  unchanged,
344
608
  skippedNoBracketId,
345
609
  noData,
346
610
  fieldAddCounts,
347
611
  previewChanges,
612
+ warnings,
613
+ warningsTruncated,
614
+ warningSummary,
348
615
  stats: {
349
616
  syncMapEntries: Object.keys(projectData.syncNumToUUID).length,
350
617
  keywordMapEntries: Object.keys(projectData.keywordMap).length,
package/sync.js CHANGED
@@ -69,10 +69,10 @@ export function inferScenePositionFromPath(syncDir, filePath) {
69
69
  let chapter = null;
70
70
 
71
71
  for (const segment of parts) {
72
- const partMatch = segment.match(/^part-(\d+)$/i);
72
+ const partMatch = segment.match(/^part-(\d+)(?:-.+)?$/i);
73
73
  if (partMatch) part = parseInt(partMatch[1], 10);
74
74
 
75
- const chapterMatch = segment.match(/^chapter-(\d+)$/i);
75
+ const chapterMatch = segment.match(/^chapter-(\d+)(?:-.+)?$/i);
76
76
  if (chapterMatch) chapter = parseInt(chapterMatch[1], 10);
77
77
  }
78
78
 
@@ -107,8 +107,8 @@ const UNIVERSE_PROJECT_ROOT_CACHE = new Map();
107
107
  function isProjectStructuralDir(name) {
108
108
  const normalized = String(name ?? "").toLowerCase();
109
109
  return PROJECT_STRUCTURAL_DIRS.has(normalized)
110
- || /^part-\d+$/.test(normalized)
111
- || /^chapter-\d+$/.test(normalized);
110
+ || /^part-\d+(?:-.+)?$/.test(normalized)
111
+ || /^chapter-\d+(?:-.+)?$/.test(normalized);
112
112
  }
113
113
 
114
114
  function isBookSlug(name) {
@@ -480,15 +480,16 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
480
480
 
481
481
  db.prepare(`
482
482
  INSERT INTO scenes (
483
- scene_id, project_id, title, part, chapter, pov, logline, scene_change,
483
+ scene_id, project_id, title, part, chapter, chapter_title, pov, logline, scene_change,
484
484
  causality, stakes, scene_functions,
485
485
  save_the_cat_beat, timeline_position, story_time, word_count,
486
486
  file_path, prose_checksum, metadata_stale, updated_at
487
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
487
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
488
488
  ON CONFLICT (scene_id, project_id) DO UPDATE SET
489
489
  title = excluded.title,
490
490
  part = excluded.part,
491
491
  chapter = excluded.chapter,
492
+ chapter_title = excluded.chapter_title,
492
493
  pov = excluded.pov,
493
494
  logline = excluded.logline,
494
495
  scene_change = excluded.scene_change,
@@ -505,7 +506,7 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
505
506
  updated_at = excluded.updated_at
506
507
  `).run(
507
508
  meta.scene_id, project_id,
508
- meta.title ?? null, meta.part ?? null, meta.chapter ?? null,
509
+ meta.title ?? null, meta.part ?? null, meta.chapter ?? null, meta.chapter_title ?? null,
509
510
  meta.pov ?? null, meta.logline ?? meta.synopsis ?? null,
510
511
  meta.scene_change ?? meta.change ?? null,
511
512
  meta.causality ?? null, meta.stakes ?? null,