@hanna84/mcp-writing 1.8.0 → 1.9.0

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.9.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.8.1...v1.9.0)
9
+
10
+ - feat: add status field to update_scene_metadata and add update_place_sheet tool [`#53`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/53)
12
+
13
+ #### [v1.8.1](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.8.0...v1.8.1)
15
+
16
+ > 19 April 2026
17
+
18
+ - Fix inferProjectAndUniverse misidentifying two-segment universe paths under projects/ [`#51`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/51)
20
+ - Release 1.8.1 [`3e97cfa`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/3e97cfaef2fc55421d43fda4157408a71ae39518)
22
+
7
23
  #### [v1.8.0](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.7.0...v1.8.0)
9
25
 
26
+ > 19 April 2026
27
+
10
28
  - feat(search): index metadata keywords in scenes FTS and preserve legacy index data [`#50`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/50)
30
+ - Release 1.8.0 [`5e3c4fd`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/5e3c4fd6fa4caade6540d3228bfa52ee5f42c923)
12
32
 
13
33
  #### [v1.7.0](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.6.3...v1.7.0)
package/README.md CHANGED
@@ -322,6 +322,7 @@ Outcome: you get AI speed with explicit approval and recoverable history for eve
322
322
  | `enrich_scene` | Re-derive lightweight metadata from current prose and clear `metadata_stale` |
323
323
  | `update_scene_metadata` | Write metadata fields back to a scene sidecar |
324
324
  | `update_character_sheet` | Write fields back to a character sidecar |
325
+ | `update_place_sheet` | Write fields back to a place sidecar |
325
326
  | `flag_scene` | Mark a scene with a flag for AI follow-up |
326
327
  | `propose_edit` | Stage a scene revision for review without writing it |
327
328
  | `commit_edit` | Apply a staged prose edit and create a git-backed snapshot |
package/index.js CHANGED
@@ -1219,6 +1219,7 @@ function createMcpServer() {
1219
1219
  fields: z.object({
1220
1220
  title: z.string().optional(),
1221
1221
  logline: z.string().optional(),
1222
+ status: z.string().optional().describe("Workflow status (e.g. 'draft', 'revision', 'complete'). Free text — no fixed vocabulary."),
1222
1223
  save_the_cat_beat: z.string().optional(),
1223
1224
  pov: z.string().optional(),
1224
1225
  part: z.number().int().optional(),
@@ -1311,6 +1312,45 @@ function createMcpServer() {
1311
1312
  }
1312
1313
  );
1313
1314
 
1315
+ // ---- update_place_sheet --------------------------------------------------
1316
+ s.tool(
1317
+ "update_place_sheet",
1318
+ "Update structured metadata fields for a place (name, associated_characters, tags). Writes to the .meta.yaml sidecar — never modifies the prose notes file. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
1319
+ {
1320
+ place_id: z.string().describe("The place_id to update (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
1321
+ fields: z.object({
1322
+ name: z.string().optional(),
1323
+ associated_characters: z.array(z.string()).optional(),
1324
+ tags: z.array(z.string()).optional(),
1325
+ }).describe("Fields to update. Only supplied keys are changed."),
1326
+ },
1327
+ async ({ place_id, fields }) => {
1328
+ if (!SYNC_DIR_WRITABLE) {
1329
+ return errorResponse("READ_ONLY", "Cannot update place: sync dir is read-only.");
1330
+ }
1331
+ const place = db.prepare(`SELECT file_path FROM places WHERE place_id = ?`).get(place_id);
1332
+ if (!place) {
1333
+ return errorResponse("NOT_FOUND", `Place '${place_id}' not found.`);
1334
+ }
1335
+ try {
1336
+ const { meta } = readMeta(place.file_path, SYNC_DIR, { writable: true });
1337
+ const updated = { ...meta, ...fields };
1338
+ writeMeta(place.file_path, updated);
1339
+
1340
+ // Update DB directly
1341
+ db.prepare(`UPDATE places SET name = ? WHERE place_id = ?`)
1342
+ .run(updated.name ?? meta.name ?? place_id, place_id);
1343
+
1344
+ return { content: [{ type: "text", text: `Updated place sheet for '${place_id}'.` }] };
1345
+ } catch (err) {
1346
+ if (err.code === "ENOENT") {
1347
+ return errorResponse("STALE_PATH", `Place file for '${place_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: place.file_path });
1348
+ }
1349
+ return errorResponse("IO_ERROR", `Failed to write place metadata for '${place_id}': ${err.message}`);
1350
+ }
1351
+ }
1352
+ );
1353
+
1314
1354
  // ---- flag_scene ----------------------------------------------------------
1315
1355
  s.tool(
1316
1356
  "flag_scene",
package/metadata-lint.js CHANGED
@@ -35,6 +35,7 @@ const sceneSchema = z.object({
35
35
  pov: z.string().min(1).optional(),
36
36
  logline: z.string().min(1).optional(),
37
37
  save_the_cat_beat: z.string().min(1).optional(),
38
+ status: z.string().min(1).optional(),
38
39
  timeline_position: z.number().int().optional(),
39
40
  story_time: z.string().min(1).optional(),
40
41
  word_count: z.number().int().nonnegative().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
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
@@ -96,17 +96,66 @@ export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
96
96
  };
97
97
  }
98
98
 
99
+ // Structural directory names that are never project slugs under projects/<id>/.
100
+ const PROJECT_STRUCTURAL_DIRS = new Set(["world", "scenes", "misc", "fragments", "feedback", "draft"]);
101
+
102
+ // Cache universe project root existence checks during sync scans.
103
+ const UNIVERSE_PROJECT_ROOT_CACHE = new Map();
104
+
105
+ // Returns true for known structural path segments directly under a project root
106
+ // (named dirs like "world", "scenes", and part-N / chapter-N path segments).
107
+ function isProjectStructuralDir(name) {
108
+ const normalized = String(name ?? "").toLowerCase();
109
+ return PROJECT_STRUCTURAL_DIRS.has(normalized)
110
+ || /^part-\d+$/.test(normalized)
111
+ || /^chapter-\d+$/.test(normalized);
112
+ }
113
+
114
+ function isBookSlug(name) {
115
+ return /^book-[a-z0-9][a-z0-9-]*$/i.test(String(name ?? ""));
116
+ }
117
+
118
+ function hasUniverseProjectRoot(syncDir, universeId, projectSlug) {
119
+ const key = `${syncDir}::${universeId}/${projectSlug}`;
120
+ if (UNIVERSE_PROJECT_ROOT_CACHE.has(key)) {
121
+ return UNIVERSE_PROJECT_ROOT_CACHE.get(key);
122
+ }
123
+
124
+ const exists = fs.existsSync(path.join(syncDir, "universes", universeId, projectSlug));
125
+ UNIVERSE_PROJECT_ROOT_CACHE.set(key, exists);
126
+ return exists;
127
+ }
128
+
99
129
  export function inferProjectAndUniverse(syncDir, filePath) {
100
130
  const rel = path.relative(syncDir, filePath);
101
131
  const parts = rel.split(path.sep);
102
132
 
103
133
  if (parts[0] === "universes" && parts.length >= 3) {
134
+ // Case-sensitive "world" intentionally matches isWorldFile() which also uses lowercase.
104
135
  if (parts[2] === "world") {
105
136
  return { universe_id: parts[1], project_id: null };
106
137
  }
107
138
  return { universe_id: parts[1], project_id: `${parts[1]}/${parts[2]}` };
108
139
  }
109
140
  if (parts[0] === "projects" && parts.length >= 2) {
141
+ // Detect accidental two-segment layout: projects/<universe>/<book>/...
142
+ // This occurs when a universe-scoped project_id (e.g. "universe-1/book-1-the-lamb")
143
+ // is written under projects/ instead of universes/.
144
+ // Detection is deliberately conservative to avoid mis-classifying valid nested
145
+ // project layouts (e.g. projects/my-novel/notes/...). All three conditions must hold:
146
+ // 1. parts[2] matches a book-* slug pattern (book-1, book-one, book-1-the-lamb, …)
147
+ // 2. parts[3] is a known structural directory (scenes, world, part-N, chapter-N, …)
148
+ // 3. A matching universes/<universe>/<book> directory exists on disk
149
+ if (
150
+ parts.length >= 4
151
+ && parts[2] !== undefined
152
+ && parts[3] !== undefined
153
+ && isBookSlug(parts[2])
154
+ && isProjectStructuralDir(parts[3])
155
+ && hasUniverseProjectRoot(syncDir, parts[1], parts[2])
156
+ ) {
157
+ return { universe_id: parts[1], project_id: `${parts[1]}/${parts[2]}` };
158
+ }
110
159
  return { universe_id: null, project_id: parts[1] };
111
160
  }
112
161
  return { universe_id: null, project_id: parts[0] ?? "default" };
@@ -539,6 +588,10 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
539
588
  }
540
589
 
541
590
  export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
591
+ // Reset per-run inference cache so filesystem changes between sync calls
592
+ // (for example after imports or path repairs) are reflected immediately.
593
+ UNIVERSE_PROJECT_ROOT_CACHE.clear();
594
+
542
595
  const files = walkFiles(syncDir);
543
596
  let indexed = 0;
544
597
  let staleMarked = 0;