@hanna84/mcp-writing 2.9.7 → 2.9.9

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
+ #### [v2.9.9](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.9.8...v2.9.9)
9
+
10
+ - refactor(db): replace ad-hoc migration checks with numbered migration… [`#102`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/102)
12
+
13
+ #### [v2.9.8](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v2.9.7...v2.9.8)
15
+
16
+ > 26 April 2026
17
+
18
+ - refactor(tools): extract registerEditingTools into tools/editing.js [`#101`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/101)
20
+ - Release 2.9.8 [`6e7d649`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/6e7d64997cd22b6111a7053ef6050b568443c6b5)
22
+
7
23
  #### [v2.9.7](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v2.9.6...v2.9.7)
9
25
 
26
+ > 26 April 2026
27
+
10
28
  - refactor(tools): extract registerStyleguideTools into tools/styleguide.js [`#100`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/100)
30
+ - Release 2.9.7 [`b2e552b`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/b2e552b30e510c7a94e80f56758cb0c8260445bf)
12
32
 
13
33
  #### [v2.9.6](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v2.9.5...v2.9.6)
package/db.js CHANGED
@@ -119,28 +119,31 @@ export const SCHEMA = `
119
119
  CREATE VIRTUAL TABLE IF NOT EXISTS scenes_fts USING fts5(
120
120
  scene_id, project_id, logline, title, keywords
121
121
  );
122
- `;
123
-
124
- export function openDb(dbPath) {
125
- const db = new DatabaseSync(dbPath);
126
- db.exec(SCHEMA);
127
122
 
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
- }
123
+ CREATE TABLE IF NOT EXISTS schema_version (
124
+ id INTEGER PRIMARY KEY CHECK (id = 1),
125
+ version INTEGER NOT NULL
126
+ );
127
+ `;
132
128
 
133
- // Rebuild legacy FTS table if it predates keyword indexing.
134
- // Preserve existing indexed rows so metadata search remains available
135
- // even before the next sync pass repopulates from source files.
136
- const ftsSql = db.prepare(`
137
- SELECT sql
138
- FROM sqlite_master
139
- WHERE type = 'table' AND name = 'scenes_fts'
140
- `).get()?.sql;
141
- if (typeof ftsSql === "string" && !ftsSql.toLowerCase().includes("keywords")) {
142
- db.exec(`BEGIN IMMEDIATE;`);
143
- try {
129
+ // Each function is applied exactly once, in order, when version < its index+1.
130
+ // Each migration runs inside a transaction with the version bump — crash-safe.
131
+ // Migrations must be idempotent (guard against already-applied state).
132
+ // Never edit existing entries — add new ones at the end.
133
+ const MIGRATIONS = [
134
+ // 1: add chapter_title column to scenes
135
+ (db) => {
136
+ const sceneColumns = db.prepare(`PRAGMA table_info(scenes)`).all();
137
+ if (!sceneColumns.some(c => c.name === "chapter_title")) {
138
+ db.exec(`ALTER TABLE scenes ADD COLUMN chapter_title TEXT;`);
139
+ }
140
+ },
141
+ // 2: rebuild FTS table to include keywords column (preserve existing rows)
142
+ (db) => {
143
+ const ftsSql = db.prepare(`
144
+ SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'scenes_fts'
145
+ `).get()?.sql;
146
+ if (typeof ftsSql === "string" && !ftsSql.toLowerCase().includes("keywords")) {
144
147
  db.exec(`
145
148
  CREATE VIRTUAL TABLE scenes_fts_migrating USING fts5(
146
149
  scene_id, project_id, logline, title, keywords
@@ -153,12 +156,40 @@ export function openDb(dbPath) {
153
156
  `);
154
157
  db.exec(`DROP TABLE scenes_fts;`);
155
158
  db.exec(`ALTER TABLE scenes_fts_migrating RENAME TO scenes_fts;`);
159
+ }
160
+ },
161
+ ];
162
+
163
+ // The version every database should reach after openDb. Not the current DB value —
164
+ // query schema_version directly if you need the live version of a specific database.
165
+ export const CURRENT_SCHEMA_VERSION = MIGRATIONS.length;
166
+
167
+ function applyMigrations(db) {
168
+ db.prepare(`INSERT OR IGNORE INTO schema_version (id, version) VALUES (1, 0)`).run();
169
+ for (;;) {
170
+ db.exec(`BEGIN IMMEDIATE;`);
171
+ try {
172
+ const { version } = db.prepare(`SELECT version FROM schema_version WHERE id = 1`).get();
173
+ if (version >= MIGRATIONS.length) {
174
+ db.exec(`COMMIT;`);
175
+ break;
176
+ }
177
+ MIGRATIONS[version](db);
178
+ // WHERE version = ? ensures the bump is monotonic: a concurrent opener
179
+ // that advanced the version first will cause this UPDATE to match 0 rows,
180
+ // which is safe — the migration is already applied.
181
+ db.prepare(`UPDATE schema_version SET version = ? WHERE id = 1 AND version = ?`).run(version + 1, version);
156
182
  db.exec(`COMMIT;`);
157
183
  } catch (err) {
158
184
  db.exec(`ROLLBACK;`);
159
185
  throw err;
160
186
  }
161
187
  }
188
+ }
162
189
 
190
+ export function openDb(dbPath) {
191
+ const db = new DatabaseSync(dbPath);
192
+ db.exec(SCHEMA);
193
+ applyMigrations(db);
163
194
  return db;
164
195
  }
package/index.js CHANGED
@@ -10,10 +10,9 @@ import { spawn } from "node:child_process";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import matter from "gray-matter";
12
12
  import yaml from "js-yaml";
13
- import { z } from "zod";
14
13
  import { openDb } from "./db.js";
15
- import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, getFileWriteDiagnostics, readMeta, indexSceneFile, sidecarPath, isStructuralProjectId } from "./sync.js";
16
- import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
14
+ import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, sidecarPath, isStructuralProjectId } from "./sync.js";
15
+ import { isGitAvailable, isGitRepository, initGitRepository, getSceneProseAtCommit } from "./git.js";
17
16
  import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
18
17
  import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
19
18
  import { STYLEGUIDE_CONFIG_BASENAME } from "./prose-styleguide.js";
@@ -23,6 +22,7 @@ import { registerSearchTools } from "./tools/search.js";
23
22
  import { registerMetadataTools } from "./tools/metadata.js";
24
23
  import { registerReviewBundleTools } from "./tools/review-bundles.js";
25
24
  import { registerStyleguideTools } from "./tools/styleguide.js";
25
+ import { registerEditingTools } from "./tools/editing.js";
26
26
 
27
27
  const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
28
28
  const DB_PATH = process.env.DB_PATH ?? "./writing.db";
@@ -1022,12 +1022,15 @@ function createMcpServer() {
1022
1022
  createCanonicalWorldEntity,
1023
1023
  resolveOutputDirWithinSync,
1024
1024
  isPathCandidateInsideSyncDir,
1025
+ pendingProposals,
1026
+ generateProposalId,
1025
1027
  };
1026
1028
  registerSyncTools(s, toolContext);
1027
1029
  registerSearchTools(s, toolContext);
1028
1030
  registerMetadataTools(s, toolContext);
1029
1031
  registerReviewBundleTools(s, toolContext);
1030
1032
  registerStyleguideTools(s, toolContext);
1033
+ registerEditingTools(s, toolContext);
1031
1034
 
1032
1035
  // ---- get_runtime_config --------------------------------------------------
1033
1036
  s.tool(
@@ -1052,297 +1055,6 @@ function createMcpServer() {
1052
1055
  );
1053
1056
 
1054
1057
  // ---- prose styleguide ---------------------------------------------------
1055
- // ---- PHASE 3: Prose Editing (git-backed) --------------------------------
1056
-
1057
- // ---- propose_edit --------------------------------------------------------
1058
- s.tool(
1059
- "propose_edit",
1060
- "Generate a proposed revision for a scene. Returns a proposal_id and a diff preview. Nothing is written yet — you must call commit_edit to apply the change. This tool requires git to be available.",
1061
- {
1062
- scene_id: z.string().describe("The scene_id to revise (e.g. 'sc-011-sebastian')."),
1063
- instruction: z.string().describe("A brief instruction for the edit (e.g. 'Tighten the opening paragraph'). Used in the git commit message."),
1064
- revised_prose: z.string().describe("The complete revised prose text for the scene."),
1065
- },
1066
- async ({ scene_id, instruction, revised_prose }) => {
1067
- if (!GIT_ENABLED) {
1068
- return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported. Ensure git is installed and the sync directory is writable.");
1069
- }
1070
-
1071
- const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
1072
- if (!scene) {
1073
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Hint: call find_scenes to get valid scene IDs.`);
1074
- }
1075
-
1076
- try {
1077
- // Read current prose
1078
- const raw = fs.readFileSync(scene.file_path, "utf8");
1079
- const { data: metadata, content: currentProse } = matter(raw);
1080
-
1081
- // Generate a simple diff representation
1082
- const currentLines = currentProse.trim().split("\n");
1083
- const revisedLines = revised_prose.trim().split("\n");
1084
- const diffLines = [];
1085
- const maxLines = Math.max(currentLines.length, revisedLines.length);
1086
-
1087
- // Simple line-by-line diff
1088
- for (let i = 0; i < Math.min(3, maxLines); i++) {
1089
- const curr = currentLines[i] || "(removed)";
1090
- const rev = revisedLines[i] || "(removed)";
1091
- if (curr !== rev) {
1092
- diffLines.push(`- ${curr.substring(0, 80)}`);
1093
- diffLines.push(`+ ${rev.substring(0, 80)}`);
1094
- }
1095
- }
1096
- if (maxLines > 3) {
1097
- diffLines.push(`... (${maxLines - 3} more lines)`);
1098
- }
1099
-
1100
- const proposalId = generateProposalId();
1101
- pendingProposals.set(proposalId, {
1102
- scene_id,
1103
- scene_file_path: scene.file_path,
1104
- instruction,
1105
- revised_prose,
1106
- original_prose: currentProse,
1107
- metadata,
1108
- created_at: new Date().toISOString(),
1109
- });
1110
-
1111
- const summary = {
1112
- proposal_id: proposalId,
1113
- scene_id,
1114
- instruction,
1115
- diff_preview: diffLines.join("\n"),
1116
- note: "Review the diff above. Call commit_edit with this proposal_id to apply the change.",
1117
- };
1118
-
1119
- return jsonResponse(summary);
1120
- } catch (err) {
1121
- if (err.code === "ENOENT") {
1122
- return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path.`, { indexed_path: scene.file_path });
1123
- }
1124
- return errorResponse("IO_ERROR", `Failed to read scene file: ${err.message}`);
1125
- }
1126
- }
1127
- );
1128
-
1129
- // ---- commit_edit ---------------------------------------------------------
1130
- s.tool(
1131
- "commit_edit",
1132
- "Apply a proposed edit and commit it to git. First creates a pre-edit snapshot, then writes the revised prose and metadata back to disk. The scene metadata stale flag is cleared.",
1133
- {
1134
- scene_id: z.string().describe("The scene_id being revised."),
1135
- proposal_id: z.string().describe("The proposal_id returned by propose_edit."),
1136
- },
1137
- async ({ scene_id, proposal_id }) => {
1138
- if (!GIT_ENABLED) {
1139
- return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported.");
1140
- }
1141
-
1142
- const proposal = pendingProposals.get(proposal_id);
1143
- if (!proposal) {
1144
- return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has expired. Hint: call propose_edit again to create a fresh proposal_id.`);
1145
- }
1146
-
1147
- if (proposal.scene_id !== scene_id) {
1148
- return errorResponse("INVALID_EDIT", `Proposal '${proposal_id}' is for scene '${proposal.scene_id}', not '${scene_id}'.`);
1149
- }
1150
-
1151
- try {
1152
- const proseWriteDiagnostics = getFileWriteDiagnostics(proposal.scene_file_path);
1153
- if (proseWriteDiagnostics.stat_error_code === "EACCES" || proseWriteDiagnostics.stat_error_code === "EPERM") {
1154
- return errorResponse(
1155
- "PROSE_FILE_NOT_WRITABLE",
1156
- "Scene prose file cannot be accessed by the current runtime user.",
1157
- {
1158
- indexed_path: proposal.scene_file_path,
1159
- prose_write_diagnostics: proseWriteDiagnostics,
1160
- }
1161
- );
1162
- }
1163
-
1164
- if (proseWriteDiagnostics.stat_error_code && proseWriteDiagnostics.stat_error_code !== "ENOENT" && proseWriteDiagnostics.stat_error_code !== "ENOTDIR") {
1165
- return errorResponse(
1166
- "IO_ERROR",
1167
- "Failed to inspect scene prose path before writing.",
1168
- {
1169
- indexed_path: proposal.scene_file_path,
1170
- prose_write_diagnostics: proseWriteDiagnostics,
1171
- }
1172
- );
1173
- }
1174
-
1175
- if (!proseWriteDiagnostics.exists) {
1176
- return errorResponse("STALE_PATH", "Prose file not found at indexed path.", {
1177
- indexed_path: proposal.scene_file_path,
1178
- prose_write_diagnostics: proseWriteDiagnostics,
1179
- });
1180
- }
1181
-
1182
- if (!proseWriteDiagnostics.is_file) {
1183
- return errorResponse("INVALID_PROSE_PATH", "Indexed prose path is not a regular file.", {
1184
- indexed_path: proposal.scene_file_path,
1185
- prose_write_diagnostics: proseWriteDiagnostics,
1186
- });
1187
- }
1188
-
1189
- if (!proseWriteDiagnostics.writable) {
1190
- return errorResponse(
1191
- "PROSE_FILE_NOT_WRITABLE",
1192
- "Scene prose file is not writable by the current runtime user.",
1193
- {
1194
- indexed_path: proposal.scene_file_path,
1195
- prose_write_diagnostics: proseWriteDiagnostics,
1196
- }
1197
- );
1198
- }
1199
-
1200
- // Reconstruct file content, preserving frontmatter only if the original had it
1201
- const hasFrontmatter = proposal.metadata && Object.keys(proposal.metadata).length > 0;
1202
- const content = hasFrontmatter
1203
- ? `---\n${yaml.dump(proposal.metadata)}---\n\n${proposal.revised_prose}\n`
1204
- : `${proposal.revised_prose}\n`;
1205
-
1206
- // Create pre-edit snapshot (commits current state before overwriting)
1207
- const snapshot = createSnapshot(SYNC_DIR, proposal.scene_file_path, scene_id, proposal.instruction);
1208
-
1209
- // Write the revised prose to disk
1210
- fs.writeFileSync(proposal.scene_file_path, content, "utf8");
1211
-
1212
- // Re-index using canonical metadata (sidecar takes precedence over inline frontmatter)
1213
- const { meta: canonicalMeta } = readMeta(proposal.scene_file_path, SYNC_DIR, { writable: false });
1214
- const { content: newProse } = matter(content);
1215
- indexSceneFile(db, SYNC_DIR, proposal.scene_file_path, canonicalMeta, newProse);
1216
-
1217
- // Clean up the proposal
1218
- pendingProposals.delete(proposal_id);
1219
-
1220
- const result = {
1221
- ok: true,
1222
- scene_id,
1223
- proposal_id,
1224
- snapshot_commit: snapshot.commit_hash,
1225
- message: `Committed edit for scene '${scene_id}'${snapshot.commit_hash ? ` (snapshot: ${snapshot.commit_hash.substring(0, 7)})` : " (no changes to snapshot)"}`,
1226
- };
1227
-
1228
- return jsonResponse(result);
1229
- } catch (err) {
1230
- if (err.code === "ENOENT") {
1231
- return errorResponse("STALE_PATH", `Prose file not found at indexed path.`, { indexed_path: proposal.scene_file_path });
1232
- }
1233
- return errorResponse("IO_ERROR", `Failed to commit edit: ${err.message}`);
1234
- }
1235
- }
1236
- );
1237
-
1238
- // ---- discard_edit --------------------------------------------------------
1239
- s.tool(
1240
- "discard_edit",
1241
- "Discard a pending proposal without applying it. The proposal is deleted and the prose remains unchanged.",
1242
- {
1243
- proposal_id: z.string().describe("The proposal_id to discard (from propose_edit)."),
1244
- },
1245
- async ({ proposal_id }) => {
1246
- const proposal = pendingProposals.get(proposal_id);
1247
- if (!proposal) {
1248
- return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has already been discarded.`);
1249
- }
1250
-
1251
- pendingProposals.delete(proposal_id);
1252
- return jsonResponse({
1253
- ok: true,
1254
- proposal_id,
1255
- message: `Discarded proposal '${proposal_id}' for scene '${proposal.scene_id}'.`,
1256
- });
1257
- }
1258
- );
1259
-
1260
- // ---- snapshot_scene -------------------------------------------------------
1261
- s.tool(
1262
- "snapshot_scene",
1263
- "Manually create a git commit (snapshot) for the current state of a scene. Use this to mark important editing checkpoints outside of the propose/commit workflow.",
1264
- {
1265
- scene_id: z.string().describe("The scene_id to snapshot."),
1266
- project_id: z.string().describe("Project the scene belongs to."),
1267
- reason: z.string().describe("A brief reason for the snapshot (e.g. 'Character arc milestone reached')."),
1268
- },
1269
- async ({ scene_id, project_id, reason }) => {
1270
- if (!GIT_ENABLED) {
1271
- return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be created.");
1272
- }
1273
-
1274
- const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
1275
- .get(scene_id, project_id);
1276
- if (!scene) {
1277
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
1278
- }
1279
-
1280
- try {
1281
- const snapshot = createSnapshot(SYNC_DIR, scene.file_path, scene_id, reason);
1282
- if (!snapshot.commit_hash) {
1283
- return jsonResponse({
1284
- ok: true,
1285
- scene_id,
1286
- reason,
1287
- message: "No changes to snapshot.",
1288
- });
1289
- }
1290
-
1291
- return jsonResponse({
1292
- ok: true,
1293
- scene_id,
1294
- reason,
1295
- commit_hash: snapshot.commit_hash,
1296
- message: `Created snapshot for scene '${scene_id}': ${reason}`,
1297
- });
1298
- } catch (err) {
1299
- if (err.code === "ENOENT") {
1300
- return errorResponse("STALE_PATH", `Prose file not found at indexed path.`, { indexed_path: scene.file_path });
1301
- }
1302
- return errorResponse("IO_ERROR", `Failed to create snapshot: ${err.message}`);
1303
- }
1304
- }
1305
- );
1306
-
1307
- // ---- list_snapshots -------------------------------------------------------
1308
- s.tool(
1309
- "list_snapshots",
1310
- "List git commit history for a scene, with timestamps and commit messages. Use this to find commit hashes for get_scene_prose historical retrieval.",
1311
- {
1312
- scene_id: z.string().describe("The scene_id to list snapshots for."),
1313
- },
1314
- async ({ scene_id }) => {
1315
- if (!GIT_ENABLED) {
1316
- return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be retrieved.");
1317
- }
1318
-
1319
- const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
1320
- if (!scene) {
1321
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found.`);
1322
- }
1323
-
1324
- try {
1325
- const snapshots = listSnapshots(SYNC_DIR, scene.file_path);
1326
- if (!snapshots || snapshots.length === 0) {
1327
- return errorResponse("NO_RESULTS", `No snapshots found for scene '${scene_id}'. Try editing and committing the scene first.`);
1328
- }
1329
-
1330
- return jsonResponse({
1331
- scene_id,
1332
- snapshots: snapshots.map(s => ({
1333
- commit_hash: s.commit_hash,
1334
- short_hash: s.commit_hash.substring(0, 7),
1335
- timestamp: s.timestamp,
1336
- message: s.message,
1337
- })),
1338
- note: "Use the commit_hash values with get_scene_prose(scene_id, commit) to retrieve a past version.",
1339
- });
1340
- } catch (err) {
1341
- return errorResponse("IO_ERROR", `Failed to list snapshots: ${err.message}`);
1342
- }
1343
- }
1344
- );
1345
-
1346
1058
  return s;
1347
1059
  }
1348
1060
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.9.7",
3
+ "version": "2.9.9",
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",
@@ -0,0 +1,293 @@
1
+ import { z } from "zod";
2
+ import fs from "node:fs";
3
+ import matter from "gray-matter";
4
+ import yaml from "js-yaml";
5
+ import { createSnapshot, listSnapshots } from "../git.js";
6
+ import { getFileWriteDiagnostics, readMeta, indexSceneFile } from "../sync.js";
7
+
8
+ export function registerEditingTools(s, {
9
+ db,
10
+ SYNC_DIR,
11
+ GIT_ENABLED,
12
+ errorResponse,
13
+ jsonResponse,
14
+ pendingProposals,
15
+ generateProposalId,
16
+ }) {
17
+ // ---- propose_edit --------------------------------------------------------
18
+ s.tool(
19
+ "propose_edit",
20
+ "Generate a proposed revision for a scene. Returns a proposal_id and a diff preview. Nothing is written yet — you must call commit_edit to apply the change. This tool requires git to be available.",
21
+ {
22
+ scene_id: z.string().describe("The scene_id to revise (e.g. 'sc-011-sebastian')."),
23
+ instruction: z.string().describe("A brief instruction for the edit (e.g. 'Tighten the opening paragraph'). Used in the git commit message."),
24
+ revised_prose: z.string().describe("The complete revised prose text for the scene."),
25
+ },
26
+ async ({ scene_id, instruction, revised_prose }) => {
27
+ if (!GIT_ENABLED) {
28
+ return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported. Ensure git is installed and the sync directory is writable.");
29
+ }
30
+
31
+ const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
32
+ if (!scene) {
33
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Hint: call find_scenes to get valid scene IDs.`);
34
+ }
35
+
36
+ try {
37
+ const raw = fs.readFileSync(scene.file_path, "utf8");
38
+ const { data: metadata, content: currentProse } = matter(raw);
39
+
40
+ const currentLines = currentProse.trim().split("\n");
41
+ const revisedLines = revised_prose.trim().split("\n");
42
+ const diffLines = [];
43
+ const maxLines = Math.max(currentLines.length, revisedLines.length);
44
+
45
+ for (let i = 0; i < Math.min(3, maxLines); i++) {
46
+ const curr = currentLines[i] || "(removed)";
47
+ const rev = revisedLines[i] || "(removed)";
48
+ if (curr !== rev) {
49
+ diffLines.push(`- ${curr.substring(0, 80)}`);
50
+ diffLines.push(`+ ${rev.substring(0, 80)}`);
51
+ }
52
+ }
53
+ if (maxLines > 3) {
54
+ diffLines.push(`... (${maxLines - 3} more lines)`);
55
+ }
56
+
57
+ const proposalId = generateProposalId();
58
+ pendingProposals.set(proposalId, {
59
+ scene_id,
60
+ scene_file_path: scene.file_path,
61
+ instruction,
62
+ revised_prose,
63
+ original_prose: currentProse,
64
+ metadata,
65
+ created_at: new Date().toISOString(),
66
+ });
67
+
68
+ return jsonResponse({
69
+ proposal_id: proposalId,
70
+ scene_id,
71
+ instruction,
72
+ diff_preview: diffLines.join("\n"),
73
+ note: "Review the diff above. Call commit_edit with this proposal_id to apply the change.",
74
+ });
75
+ } catch (err) {
76
+ if (err.code === "ENOENT") {
77
+ return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path.`, { indexed_path: scene.file_path });
78
+ }
79
+ return errorResponse("IO_ERROR", `Failed to read scene file: ${err.message}`);
80
+ }
81
+ }
82
+ );
83
+
84
+ // ---- commit_edit ---------------------------------------------------------
85
+ s.tool(
86
+ "commit_edit",
87
+ "Apply a proposed edit and commit it to git. First creates a pre-edit snapshot, then writes the revised prose and metadata back to disk. The scene metadata stale flag is cleared.",
88
+ {
89
+ scene_id: z.string().describe("The scene_id being revised."),
90
+ proposal_id: z.string().describe("The proposal_id returned by propose_edit."),
91
+ },
92
+ async ({ scene_id, proposal_id }) => {
93
+ if (!GIT_ENABLED) {
94
+ return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported.");
95
+ }
96
+
97
+ const proposal = pendingProposals.get(proposal_id);
98
+ if (!proposal) {
99
+ return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has expired. Hint: call propose_edit again to create a fresh proposal_id.`);
100
+ }
101
+
102
+ if (proposal.scene_id !== scene_id) {
103
+ return errorResponse("INVALID_EDIT", `Proposal '${proposal_id}' is for scene '${proposal.scene_id}', not '${scene_id}'.`);
104
+ }
105
+
106
+ try {
107
+ const proseWriteDiagnostics = getFileWriteDiagnostics(proposal.scene_file_path);
108
+ if (proseWriteDiagnostics.stat_error_code === "EACCES" || proseWriteDiagnostics.stat_error_code === "EPERM") {
109
+ return errorResponse(
110
+ "PROSE_FILE_NOT_WRITABLE",
111
+ "Scene prose file cannot be accessed by the current runtime user.",
112
+ {
113
+ indexed_path: proposal.scene_file_path,
114
+ prose_write_diagnostics: proseWriteDiagnostics,
115
+ }
116
+ );
117
+ }
118
+
119
+ if (proseWriteDiagnostics.stat_error_code && proseWriteDiagnostics.stat_error_code !== "ENOENT" && proseWriteDiagnostics.stat_error_code !== "ENOTDIR") {
120
+ return errorResponse(
121
+ "IO_ERROR",
122
+ "Failed to inspect scene prose path before writing.",
123
+ {
124
+ indexed_path: proposal.scene_file_path,
125
+ prose_write_diagnostics: proseWriteDiagnostics,
126
+ }
127
+ );
128
+ }
129
+
130
+ if (!proseWriteDiagnostics.exists) {
131
+ return errorResponse("STALE_PATH", "Prose file not found at indexed path.", {
132
+ indexed_path: proposal.scene_file_path,
133
+ prose_write_diagnostics: proseWriteDiagnostics,
134
+ });
135
+ }
136
+
137
+ if (!proseWriteDiagnostics.is_file) {
138
+ return errorResponse("INVALID_PROSE_PATH", "Indexed prose path is not a regular file.", {
139
+ indexed_path: proposal.scene_file_path,
140
+ prose_write_diagnostics: proseWriteDiagnostics,
141
+ });
142
+ }
143
+
144
+ if (!proseWriteDiagnostics.writable) {
145
+ return errorResponse(
146
+ "PROSE_FILE_NOT_WRITABLE",
147
+ "Scene prose file is not writable by the current runtime user.",
148
+ {
149
+ indexed_path: proposal.scene_file_path,
150
+ prose_write_diagnostics: proseWriteDiagnostics,
151
+ }
152
+ );
153
+ }
154
+
155
+ const hasFrontmatter = proposal.metadata && Object.keys(proposal.metadata).length > 0;
156
+ const content = hasFrontmatter
157
+ ? `---\n${yaml.dump(proposal.metadata)}---\n\n${proposal.revised_prose}\n`
158
+ : `${proposal.revised_prose}\n`;
159
+
160
+ const snapshot = createSnapshot(SYNC_DIR, proposal.scene_file_path, scene_id, proposal.instruction);
161
+
162
+ fs.writeFileSync(proposal.scene_file_path, content, "utf8");
163
+
164
+ const { meta: canonicalMeta } = readMeta(proposal.scene_file_path, SYNC_DIR, { writable: false });
165
+ const { content: newProse } = matter(content);
166
+ indexSceneFile(db, SYNC_DIR, proposal.scene_file_path, canonicalMeta, newProse);
167
+
168
+ pendingProposals.delete(proposal_id);
169
+
170
+ return jsonResponse({
171
+ ok: true,
172
+ scene_id,
173
+ proposal_id,
174
+ snapshot_commit: snapshot.commit_hash,
175
+ message: `Committed edit for scene '${scene_id}'${snapshot.commit_hash ? ` (snapshot: ${snapshot.commit_hash.substring(0, 7)})` : " (no changes to snapshot)"}`,
176
+ });
177
+ } catch (err) {
178
+ if (err.code === "ENOENT") {
179
+ return errorResponse("STALE_PATH", `Prose file not found at indexed path.`, { indexed_path: proposal.scene_file_path });
180
+ }
181
+ return errorResponse("IO_ERROR", `Failed to commit edit: ${err.message}`);
182
+ }
183
+ }
184
+ );
185
+
186
+ // ---- discard_edit --------------------------------------------------------
187
+ s.tool(
188
+ "discard_edit",
189
+ "Discard a pending proposal without applying it. The proposal is deleted and the prose remains unchanged.",
190
+ {
191
+ proposal_id: z.string().describe("The proposal_id to discard (from propose_edit)."),
192
+ },
193
+ async ({ proposal_id }) => {
194
+ const proposal = pendingProposals.get(proposal_id);
195
+ if (!proposal) {
196
+ return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has already been discarded.`);
197
+ }
198
+
199
+ pendingProposals.delete(proposal_id);
200
+ return jsonResponse({
201
+ ok: true,
202
+ proposal_id,
203
+ message: `Discarded proposal '${proposal_id}' for scene '${proposal.scene_id}'.`,
204
+ });
205
+ }
206
+ );
207
+
208
+ // ---- snapshot_scene -------------------------------------------------------
209
+ s.tool(
210
+ "snapshot_scene",
211
+ "Manually create a git commit (snapshot) for the current state of a scene. Use this to mark important editing checkpoints outside of the propose/commit workflow.",
212
+ {
213
+ scene_id: z.string().describe("The scene_id to snapshot."),
214
+ project_id: z.string().describe("Project the scene belongs to."),
215
+ reason: z.string().describe("A brief reason for the snapshot (e.g. 'Character arc milestone reached')."),
216
+ },
217
+ async ({ scene_id, project_id, reason }) => {
218
+ if (!GIT_ENABLED) {
219
+ return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be created.");
220
+ }
221
+
222
+ const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
223
+ .get(scene_id, project_id);
224
+ if (!scene) {
225
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
226
+ }
227
+
228
+ try {
229
+ const snapshot = createSnapshot(SYNC_DIR, scene.file_path, scene_id, reason);
230
+ if (!snapshot.commit_hash) {
231
+ return jsonResponse({
232
+ ok: true,
233
+ scene_id,
234
+ reason,
235
+ message: "No changes to snapshot.",
236
+ });
237
+ }
238
+
239
+ return jsonResponse({
240
+ ok: true,
241
+ scene_id,
242
+ reason,
243
+ commit_hash: snapshot.commit_hash,
244
+ message: `Created snapshot for scene '${scene_id}': ${reason}`,
245
+ });
246
+ } catch (err) {
247
+ if (err.code === "ENOENT") {
248
+ return errorResponse("STALE_PATH", `Prose file not found at indexed path.`, { indexed_path: scene.file_path });
249
+ }
250
+ return errorResponse("IO_ERROR", `Failed to create snapshot: ${err.message}`);
251
+ }
252
+ }
253
+ );
254
+
255
+ // ---- list_snapshots -------------------------------------------------------
256
+ s.tool(
257
+ "list_snapshots",
258
+ "List git commit history for a scene, with timestamps and commit messages. Use this to find commit hashes for get_scene_prose historical retrieval.",
259
+ {
260
+ scene_id: z.string().describe("The scene_id to list snapshots for."),
261
+ },
262
+ async ({ scene_id }) => {
263
+ if (!GIT_ENABLED) {
264
+ return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be retrieved.");
265
+ }
266
+
267
+ const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
268
+ if (!scene) {
269
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found.`);
270
+ }
271
+
272
+ try {
273
+ const snapshots = listSnapshots(SYNC_DIR, scene.file_path);
274
+ if (!snapshots || snapshots.length === 0) {
275
+ return errorResponse("NO_RESULTS", `No snapshots found for scene '${scene_id}'. Try editing and committing the scene first.`);
276
+ }
277
+
278
+ return jsonResponse({
279
+ scene_id,
280
+ snapshots: snapshots.map(s => ({
281
+ commit_hash: s.commit_hash,
282
+ short_hash: s.commit_hash.substring(0, 7),
283
+ timestamp: s.timestamp,
284
+ message: s.message,
285
+ })),
286
+ note: "Use the commit_hash values with get_scene_prose(scene_id, commit) to retrieve a past version.",
287
+ });
288
+ } catch (err) {
289
+ return errorResponse("IO_ERROR", `Failed to list snapshots: ${err.message}`);
290
+ }
291
+ }
292
+ );
293
+ }