@hanna84/mcp-writing 1.5.2 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.6.1](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.6.0...v1.6.1)
9
+
10
+ - fix: preflight prose writes and make canonical sheet creation idempotent [`#46`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/46)
12
+
13
+ #### [v1.6.0](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.5.2...v1.6.0)
15
+
16
+ > 19 April 2026
17
+
18
+ - feat: add permission preflight diagnostics and ops contract [`#45`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/45)
20
+ - Release 1.6.0 [`bfab6d2`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/bfab6d205f4c60bf9e59cb4097796fc7cef63158)
22
+
7
23
  #### [v1.5.2](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.5.1...v1.5.2)
9
25
 
26
+ > 19 April 2026
27
+
10
28
  - fix: avoid nested scenes/projects paths on import [`#44`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/44)
30
+ - Release 1.5.2 [`47f7c6c`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/47f7c6c9ddbc4244b8a62b1dc37c86eb914aafbc)
12
32
 
13
33
  #### [v1.5.1](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.5.0...v1.5.1)
package/README.md CHANGED
@@ -34,6 +34,24 @@ npm --version # should be 8.0.0 or later
34
34
  git --version # should be installed
35
35
  ```
36
36
 
37
+ ## Permission Contract (Recommended For All Users)
38
+
39
+ To keep MCP write tools reliable across local runs, Docker, and AI agents, use this contract:
40
+
41
+ 1. The same non-root user should own and write the sync directory.
42
+ 2. Containerized runs should use host UID/GID, not root.
43
+ 3. If ownership drifts (for example root-owned files), repair once on host and continue.
44
+
45
+ Repair commands (host):
46
+
47
+ ```sh
48
+ sudo chown -R "$(id -u):$(id -g)" /path/to/sync-dir
49
+ find /path/to/sync-dir -type d -exec chmod u+rwx {} +
50
+ find /path/to/sync-dir -type f -exec chmod u+rw {} +
51
+ ```
52
+
53
+ You can also inspect ownership/writability status at runtime via `get_runtime_config`.
54
+
37
55
  ## First-time setup path (recommended)
38
56
 
39
57
  If this is your first time, follow these steps in order:
@@ -321,6 +339,7 @@ Paginated tools (`find_scenes`, `get_arc`, `list_threads`, `get_thread_arc`, `se
321
339
  # docker-compose.yml snippet
322
340
  writing-mcp:
323
341
  build: .
342
+ user: "${UID:-1000}:${GID:-1000}"
324
343
  environment:
325
344
  WRITING_SYNC_DIR: /sync
326
345
  DB_PATH: /data/writing.db
@@ -338,6 +357,8 @@ volumes:
338
357
  writing-mcp-data:
339
358
  ```
340
359
 
360
+ If you want explicit host mapping, set `UID` and `GID` in your shell or a `.env` file next to `docker-compose.yml`.
361
+
341
362
  Then register in your OpenClaw config:
342
363
 
343
364
  ```json
package/index.js CHANGED
@@ -7,7 +7,7 @@ import matter from "gray-matter";
7
7
  import yaml from "js-yaml";
8
8
  import { z } from "zod";
9
9
  import { openDb } from "./db.js";
10
- import { syncAll, isSyncDirWritable, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
10
+ import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, getFileWriteDiagnostics, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
11
11
  import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
12
12
  import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
13
13
  import { importScrivenerSync, validateProjectId } from "./importer.js";
@@ -180,28 +180,72 @@ function createCanonicalWorldEntity({ kind, name, notes, projectId, universeId,
180
180
  const { dir } = resolveWorldEntityDir({ kind, projectId, universeId, name });
181
181
  const prosePath = path.join(dir, "sheet.md");
182
182
  const metaPath = sidecarPath(prosePath);
183
- if (fs.existsSync(prosePath) || fs.existsSync(metaPath)) {
184
- throw new Error(`Canonical sheet already exists at ${dir}`);
183
+ const hadProse = fs.existsSync(prosePath);
184
+ const hadMeta = fs.existsSync(metaPath);
185
+
186
+ let shouldWriteMeta = !hadMeta;
187
+ let payload;
188
+ const derivedId = `${prefix}-${slug}`;
189
+ if (hadMeta) {
190
+ let parsedMeta;
191
+ try {
192
+ parsedMeta = yaml.load(fs.readFileSync(metaPath, "utf8"));
193
+ } catch (err) {
194
+ throw new Error(
195
+ `Existing metadata sidecar is invalid YAML at ${metaPath}: ${err.message}`,
196
+ { cause: err }
197
+ );
198
+ }
199
+
200
+ if (parsedMeta != null && (typeof parsedMeta !== "object" || Array.isArray(parsedMeta))) {
201
+ throw new Error(`Existing metadata sidecar must be a YAML mapping at ${metaPath}.`);
202
+ }
203
+
204
+ const existingMeta = parsedMeta ?? {};
205
+
206
+ const backfilledId = existingMeta[idKey] ?? derivedId;
207
+ const backfilledName = existingMeta.name ?? name;
208
+ shouldWriteMeta = existingMeta[idKey] == null || existingMeta.name == null;
209
+ payload = shouldWriteMeta
210
+ ? {
211
+ ...existingMeta,
212
+ [idKey]: backfilledId,
213
+ name: backfilledName,
214
+ }
215
+ : existingMeta;
216
+ } else {
217
+ payload = {
218
+ [idKey]: derivedId,
219
+ name,
220
+ ...(meta ?? {}),
221
+ };
185
222
  }
186
223
 
187
224
  fs.mkdirSync(dir, { recursive: true });
188
- const defaultSheet = kind === "character"
189
- ? renderCharacterSheetTemplate(name)
190
- : renderPlaceSheetTemplate(name);
191
- fs.writeFileSync(prosePath, `${notes?.trim() ?? defaultSheet}${(notes?.trim() ?? defaultSheet) ? "\n" : ""}`, "utf8");
225
+
226
+ if (!hadProse) {
227
+ const defaultSheet = kind === "character"
228
+ ? renderCharacterSheetTemplate(name)
229
+ : renderPlaceSheetTemplate(name);
230
+ const body = notes?.trim() ?? defaultSheet;
231
+ fs.writeFileSync(prosePath, `${body}${body ? "\n" : ""}`, "utf8");
232
+ }
233
+
192
234
  if (kind === "character") {
193
- fs.writeFileSync(path.join(dir, "arc.md"), `${renderCharacterArcTemplate(name)}\n`, "utf8");
235
+ const arcPath = path.join(dir, "arc.md");
236
+ if (!fs.existsSync(arcPath)) {
237
+ fs.writeFileSync(arcPath, `${renderCharacterArcTemplate(name)}\n`, "utf8");
238
+ }
239
+ }
240
+
241
+ if (shouldWriteMeta) {
242
+ fs.writeFileSync(metaPath, yaml.dump(payload, { lineWidth: 120 }), "utf8");
194
243
  }
195
- const payload = {
196
- [idKey]: `${prefix}-${slug}`,
197
- name,
198
- ...meta,
199
- };
200
- fs.writeFileSync(metaPath, yaml.dump(payload, { lineWidth: 120 }), "utf8");
201
244
 
202
245
  syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
203
246
 
204
247
  return {
248
+ created: !hadProse && !hadMeta,
205
249
  id: payload[idKey],
206
250
  prose_path: prosePath,
207
251
  meta_path: metaPath,
@@ -220,6 +264,7 @@ process.stderr.write(`[mcp-writing] DB path: ${DB_PATH_DISPLAY}\n`);
220
264
 
221
265
  // Check sync dir writability once at startup (needed for Phase 2 sidecar writes)
222
266
  const SYNC_DIR_WRITABLE = isSyncDirWritable(SYNC_DIR);
267
+ const SYNC_OWNERSHIP_DIAGNOSTICS = getSyncOwnershipDiagnostics(SYNC_DIR);
223
268
  if (!SYNC_DIR_WRITABLE) {
224
269
  process.stderr.write(`[mcp-writing] WARNING: sync dir is not writable — sidecar auto-migration and metadata write-back will be unavailable\n`);
225
270
  }
@@ -263,6 +308,24 @@ function getRuntimeDiagnostics() {
263
308
  recommendations.push("If running in Docker/OpenClaw, verify volume ownership and permissions for the container user.");
264
309
  }
265
310
 
311
+ if (SYNC_OWNERSHIP_DIAGNOSTICS.supported && SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths > 0) {
312
+ warnings.push(
313
+ `OWNERSHIP_MISMATCH: ${SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths} sampled path(s) are not owned by runtime UID ${SYNC_OWNERSHIP_DIAGNOSTICS.runtime_uid}.`
314
+ );
315
+ recommendations.push(
316
+ `Repair ownership once on host: sudo chown -R "$(id -u):$(id -g)" "${SYNC_DIR_ABS}"`
317
+ );
318
+ recommendations.push(
319
+ "For Docker, run container as host user (compose: user: \"${UID:-1000}:${GID:-1000}\"). Optionally set UID/GID explicitly in a .env file."
320
+ );
321
+ }
322
+
323
+ if (SYNC_OWNERSHIP_DIAGNOSTICS.supported && SYNC_OWNERSHIP_DIAGNOSTICS.root_owned_paths > 0) {
324
+ warnings.push(
325
+ `ROOT_OWNED_PATHS: ${SYNC_OWNERSHIP_DIAGNOSTICS.root_owned_paths} sampled path(s) are owned by UID 0 (root).`
326
+ );
327
+ }
328
+
266
329
  if (!GIT_AVAILABLE) {
267
330
  warnings.push("GIT_NOT_FOUND: git is not available on PATH; snapshot/edit tools are unavailable.");
268
331
  recommendations.push("Install git in the runtime image/environment.");
@@ -396,13 +459,14 @@ function createMcpServer() {
396
459
  // ---- get_runtime_config --------------------------------------------------
397
460
  s.tool(
398
461
  "get_runtime_config",
399
- "Show the active runtime paths and capabilities for this server instance (sync dir, database path, writability, and git availability). Use this to verify which manuscript location is currently connected.",
462
+ "Show the active runtime paths and capabilities for this server instance (sync dir, database path, writability, permission diagnostics, and git availability). Use this to verify which manuscript location is currently connected.",
400
463
  {},
401
464
  async () => {
402
465
  return jsonResponse({
403
466
  sync_dir: SYNC_DIR_ABS,
404
467
  db_path: DB_PATH_DISPLAY,
405
468
  sync_dir_writable: SYNC_DIR_WRITABLE,
469
+ permission_diagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
406
470
  git_available: GIT_AVAILABLE,
407
471
  git_enabled: GIT_ENABLED,
408
472
  http_port: HTTP_PORT,
@@ -691,7 +755,7 @@ function createMcpServer() {
691
755
  // ---- create_character_sheet ---------------------------------------------
692
756
  s.tool(
693
757
  "create_character_sheet",
694
- "Create a canonical character sheet folder with sheet.md and sheet.meta.yaml so the character can be indexed immediately. Use this before migrating freeform notes into the new folder structure.",
758
+ "Create or reuse a canonical character sheet folder with sheet.md and sheet.meta.yaml so the character can be indexed immediately. If the folder already exists, missing canonical files are backfilled and the existing sheet is preserved.",
695
759
  {
696
760
  name: z.string().describe("Display name of the character (e.g. 'Mira Nystrom')."),
697
761
  project_id: z.string().optional().describe("Project scope for a book-local character (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
@@ -713,7 +777,7 @@ function createMcpServer() {
713
777
  }
714
778
 
715
779
  try {
716
- const created = createCanonicalWorldEntity({
780
+ const result = createCanonicalWorldEntity({
717
781
  kind: "character",
718
782
  name,
719
783
  notes,
@@ -722,7 +786,7 @@ function createMcpServer() {
722
786
  meta: fields ?? {},
723
787
  });
724
788
 
725
- return jsonResponse({ ok: true, action: "created", kind: "character", ...created });
789
+ return jsonResponse({ ok: true, action: result.created ? "created" : "exists", kind: "character", ...result });
726
790
  } catch (err) {
727
791
  return errorResponse("IO_ERROR", `Failed to create character sheet: ${err.message}`);
728
792
  }
@@ -757,7 +821,7 @@ function createMcpServer() {
757
821
  // ---- create_place_sheet -------------------------------------------------
758
822
  s.tool(
759
823
  "create_place_sheet",
760
- "Create a canonical place sheet folder with sheet.md and sheet.meta.yaml so the place can be indexed immediately. Use this before migrating freeform notes into the new folder structure.",
824
+ "Create or reuse a canonical place sheet folder with sheet.md and sheet.meta.yaml so the place can be indexed immediately. If the folder already exists, missing canonical files are backfilled and the existing sheet is preserved.",
761
825
  {
762
826
  name: z.string().describe("Display name of the place (e.g. 'University Hospital')."),
763
827
  project_id: z.string().optional().describe("Project scope for a book-local place (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
@@ -777,7 +841,7 @@ function createMcpServer() {
777
841
  }
778
842
 
779
843
  try {
780
- const created = createCanonicalWorldEntity({
844
+ const result = createCanonicalWorldEntity({
781
845
  kind: "place",
782
846
  name,
783
847
  notes,
@@ -786,7 +850,7 @@ function createMcpServer() {
786
850
  meta: fields ?? {},
787
851
  });
788
852
 
789
- return jsonResponse({ ok: true, action: "created", kind: "place", ...created });
853
+ return jsonResponse({ ok: true, action: result.created ? "created" : "exists", kind: "place", ...result });
790
854
  } catch (err) {
791
855
  return errorResponse("IO_ERROR", `Failed to create place sheet: ${err.message}`);
792
856
  }
@@ -1363,6 +1427,54 @@ function createMcpServer() {
1363
1427
  }
1364
1428
 
1365
1429
  try {
1430
+ const proseWriteDiagnostics = getFileWriteDiagnostics(proposal.scene_file_path);
1431
+ if (proseWriteDiagnostics.stat_error_code === "EACCES" || proseWriteDiagnostics.stat_error_code === "EPERM") {
1432
+ return errorResponse(
1433
+ "PROSE_FILE_NOT_WRITABLE",
1434
+ "Scene prose file cannot be accessed by the current runtime user.",
1435
+ {
1436
+ indexed_path: proposal.scene_file_path,
1437
+ prose_write_diagnostics: proseWriteDiagnostics,
1438
+ }
1439
+ );
1440
+ }
1441
+
1442
+ if (proseWriteDiagnostics.stat_error_code && proseWriteDiagnostics.stat_error_code !== "ENOENT" && proseWriteDiagnostics.stat_error_code !== "ENOTDIR") {
1443
+ return errorResponse(
1444
+ "IO_ERROR",
1445
+ "Failed to inspect scene prose path before writing.",
1446
+ {
1447
+ indexed_path: proposal.scene_file_path,
1448
+ prose_write_diagnostics: proseWriteDiagnostics,
1449
+ }
1450
+ );
1451
+ }
1452
+
1453
+ if (!proseWriteDiagnostics.exists) {
1454
+ return errorResponse("STALE_PATH", "Prose file not found at indexed path.", {
1455
+ indexed_path: proposal.scene_file_path,
1456
+ prose_write_diagnostics: proseWriteDiagnostics,
1457
+ });
1458
+ }
1459
+
1460
+ if (!proseWriteDiagnostics.is_file) {
1461
+ return errorResponse("INVALID_PROSE_PATH", "Indexed prose path is not a regular file.", {
1462
+ indexed_path: proposal.scene_file_path,
1463
+ prose_write_diagnostics: proseWriteDiagnostics,
1464
+ });
1465
+ }
1466
+
1467
+ if (!proseWriteDiagnostics.writable) {
1468
+ return errorResponse(
1469
+ "PROSE_FILE_NOT_WRITABLE",
1470
+ "Scene prose file is not writable by the current runtime user.",
1471
+ {
1472
+ indexed_path: proposal.scene_file_path,
1473
+ prose_write_diagnostics: proseWriteDiagnostics,
1474
+ }
1475
+ );
1476
+ }
1477
+
1366
1478
  // Reconstruct file content, preserving frontmatter only if the original had it
1367
1479
  const hasFrontmatter = proposal.metadata && Object.keys(proposal.metadata).length > 0;
1368
1480
  const content = hasFrontmatter
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.5.2",
3
+ "version": "1.6.1",
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",
package/sync.js CHANGED
@@ -53,6 +53,11 @@ export function walkSidecars(dir, fileList = []) {
53
53
  return fileList;
54
54
  }
55
55
 
56
+ function isNestedMirrorPath(syncDir, filePath) {
57
+ const rel = path.relative(syncDir, filePath).split(path.sep).join("/");
58
+ return rel.includes("/scenes/projects/") || rel.includes("/scenes/universes/");
59
+ }
60
+
56
61
  export function sidecarPath(filePath) {
57
62
  return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
58
63
  }
@@ -204,6 +209,142 @@ export function isSyncDirWritable(syncDir) {
204
209
  }
205
210
  }
206
211
 
212
+ function collectOwnershipSample(rootDir, limit = 200) {
213
+ const samples = [];
214
+ const stack = [rootDir];
215
+
216
+ while (stack.length && samples.length < limit) {
217
+ const current = stack.pop();
218
+ samples.push(current);
219
+
220
+ let entries;
221
+ try {
222
+ entries = fs.readdirSync(current, { withFileTypes: true });
223
+ } catch {
224
+ continue;
225
+ }
226
+
227
+ for (const entry of entries) {
228
+ if (samples.length + stack.length >= limit) break;
229
+ if (entry.isSymbolicLink()) continue;
230
+ const full = path.join(current, entry.name);
231
+ if (entry.isDirectory()) {
232
+ stack.push(full);
233
+ } else {
234
+ samples.push(full);
235
+ if (samples.length >= limit) break;
236
+ }
237
+ }
238
+ }
239
+
240
+ return samples;
241
+ }
242
+
243
+ export function getSyncOwnershipDiagnostics(syncDir, { sampleLimit = 200 } = {}) {
244
+ const runtimeUid = typeof process.getuid === "function" ? process.getuid() : null;
245
+ let syncDirPathExists;
246
+ let syncDirIsDirectory;
247
+ try {
248
+ const stat = fs.statSync(syncDir);
249
+ syncDirPathExists = true;
250
+ syncDirIsDirectory = stat.isDirectory();
251
+ } catch {
252
+ syncDirPathExists = false;
253
+ syncDirIsDirectory = false;
254
+ }
255
+
256
+ const diagnostics = {
257
+ sync_dir: path.resolve(syncDir),
258
+ sync_dir_path_exists: syncDirPathExists,
259
+ sync_dir_is_directory: syncDirIsDirectory,
260
+ // Backwards-compatible: "exists" now means "exists and is a directory".
261
+ sync_dir_exists: syncDirIsDirectory,
262
+ supported: runtimeUid !== null,
263
+ runtime_uid: runtimeUid,
264
+ sampled_paths: 0,
265
+ sample_limit: sampleLimit,
266
+ root_owned_paths: 0,
267
+ non_runtime_owned_paths: 0,
268
+ unreadable_paths: 0,
269
+ root_owned_examples: [],
270
+ non_runtime_owned_examples: [],
271
+ };
272
+
273
+ if (!diagnostics.sync_dir_is_directory || runtimeUid === null) {
274
+ return diagnostics;
275
+ }
276
+
277
+ const sample = collectOwnershipSample(syncDir, sampleLimit);
278
+ diagnostics.sampled_paths = sample.length;
279
+
280
+ for (const filePath of sample) {
281
+ try {
282
+ const stat = fs.statSync(filePath);
283
+ const rel = path.relative(syncDir, filePath) || ".";
284
+ if (stat.uid === 0) {
285
+ diagnostics.root_owned_paths++;
286
+ if (diagnostics.root_owned_examples.length < 5) diagnostics.root_owned_examples.push(rel);
287
+ }
288
+ if (stat.uid !== runtimeUid) {
289
+ diagnostics.non_runtime_owned_paths++;
290
+ if (diagnostics.non_runtime_owned_examples.length < 5) diagnostics.non_runtime_owned_examples.push(rel);
291
+ }
292
+ } catch {
293
+ diagnostics.unreadable_paths++;
294
+ }
295
+ }
296
+
297
+ return diagnostics;
298
+ }
299
+
300
+ export function getFileWriteDiagnostics(filePath) {
301
+ const runtimeUid = typeof process.getuid === "function" ? process.getuid() : null;
302
+ const resolvedPath = path.resolve(filePath);
303
+ const parentDir = path.dirname(resolvedPath);
304
+ const diagnostics = {
305
+ path: resolvedPath,
306
+ parent_dir: parentDir,
307
+ exists: false,
308
+ is_file: false,
309
+ writable: false,
310
+ parent_dir_writable: false,
311
+ supported: runtimeUid !== null,
312
+ runtime_uid: runtimeUid,
313
+ owner_uid: null,
314
+ root_owned: false,
315
+ stat_error_code: null,
316
+ stat_error_message: null,
317
+ };
318
+
319
+ try {
320
+ fs.accessSync(parentDir, fs.constants.W_OK);
321
+ diagnostics.parent_dir_writable = true;
322
+ } catch {
323
+ diagnostics.parent_dir_writable = false;
324
+ }
325
+
326
+ try {
327
+ const stat = fs.statSync(resolvedPath);
328
+ diagnostics.exists = true;
329
+ diagnostics.is_file = stat.isFile();
330
+ diagnostics.owner_uid = typeof stat.uid === "number" ? stat.uid : null;
331
+ diagnostics.root_owned = stat.uid === 0;
332
+ } catch (err) {
333
+ diagnostics.stat_error_code = typeof err?.code === "string" ? err.code : null;
334
+ diagnostics.stat_error_message = typeof err?.message === "string" ? err.message : String(err);
335
+ return diagnostics;
336
+ }
337
+
338
+ try {
339
+ fs.accessSync(resolvedPath, fs.constants.W_OK);
340
+ diagnostics.writable = diagnostics.is_file;
341
+ } catch {
342
+ diagnostics.writable = false;
343
+ }
344
+
345
+ return diagnostics;
346
+ }
347
+
207
348
  // ---------------------------------------------------------------------------
208
349
  // DB-dependent sync (takes db + syncDir as arguments for testability)
209
350
  // ---------------------------------------------------------------------------
@@ -368,9 +509,18 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
368
509
  const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
369
510
  const warnings = [];
370
511
 
512
+ const scanFiles = [];
513
+ for (const file of files) {
514
+ if (isNestedMirrorPath(syncDir, file)) {
515
+ warnings.push(`Ignored nested mirror path: ${path.relative(syncDir, file)}`);
516
+ continue;
517
+ }
518
+ scanFiles.push(file);
519
+ }
520
+
371
521
  // --- Pass 1: world files (characters/places must be indexed before scenes
372
522
  // so that character name → ID resolution in scene_characters works) ---
373
- for (const file of files) {
523
+ for (const file of scanFiles) {
374
524
  if (!isWorldFile(syncDir, file)) continue;
375
525
  try {
376
526
  const { meta } = readMeta(file, syncDir, { writable });
@@ -386,7 +536,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
386
536
  }
387
537
 
388
538
  // --- Pass 2: scene files ---
389
- for (const file of files) {
539
+ for (const file of scanFiles) {
390
540
  if (isWorldFile(syncDir, file)) continue;
391
541
  try {
392
542
  const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
@@ -431,7 +581,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
431
581
  }
432
582
 
433
583
  // --- Orphaned sidecar detection ---
434
- const sidecars = walkSidecars(syncDir);
584
+ const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
435
585
  for (const sidecar of sidecars) {
436
586
  const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
437
587
  const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");