@hanna84/mcp-writing 2.9.6 → 2.9.8

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/index.js CHANGED
@@ -10,37 +10,19 @@ 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
- import { validateProjectId } from "./importer.js";
19
17
  import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
20
- import {
21
- STYLEGUIDE_CONFIG_BASENAME,
22
- STYLEGUIDE_ENUMS,
23
- buildStyleguideConfigDraft,
24
- previewStyleguideConfigUpdate,
25
- resolveStyleguideConfig,
26
- summarizeStyleguideConfig,
27
- updateStyleguideConfig,
28
- } from "./prose-styleguide.js";
29
- import {
30
- detectStyleguideSignals,
31
- analyzeSceneStyleguideDrift,
32
- suggestStyleguideUpdatesFromScenes,
33
- } from "./prose-styleguide-drift.js";
34
- import {
35
- PROSE_STYLEGUIDE_SKILL_BASENAME,
36
- PROSE_STYLEGUIDE_SKILL_DIRNAME,
37
- buildProseStyleguideSkill,
38
- } from "./prose-styleguide-skill.js";
18
+ import { STYLEGUIDE_CONFIG_BASENAME } from "./prose-styleguide.js";
39
19
  import { ReviewBundlePlanError } from "./review-bundles.js";
40
20
  import { registerSyncTools } from "./tools/sync.js";
41
21
  import { registerSearchTools } from "./tools/search.js";
42
22
  import { registerMetadataTools } from "./tools/metadata.js";
43
23
  import { registerReviewBundleTools } from "./tools/review-bundles.js";
24
+ import { registerStyleguideTools } from "./tools/styleguide.js";
25
+ import { registerEditingTools } from "./tools/editing.js";
44
26
 
45
27
  const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
46
28
  const DB_PATH = process.env.DB_PATH ?? "./writing.db";
@@ -1039,11 +1021,16 @@ function createMcpServer() {
1039
1021
  readEntityMetadata,
1040
1022
  createCanonicalWorldEntity,
1041
1023
  resolveOutputDirWithinSync,
1024
+ isPathCandidateInsideSyncDir,
1025
+ pendingProposals,
1026
+ generateProposalId,
1042
1027
  };
1043
1028
  registerSyncTools(s, toolContext);
1044
1029
  registerSearchTools(s, toolContext);
1045
1030
  registerMetadataTools(s, toolContext);
1046
1031
  registerReviewBundleTools(s, toolContext);
1032
+ registerStyleguideTools(s, toolContext);
1033
+ registerEditingTools(s, toolContext);
1047
1034
 
1048
1035
  // ---- get_runtime_config --------------------------------------------------
1049
1036
  s.tool(
@@ -1068,946 +1055,6 @@ function createMcpServer() {
1068
1055
  );
1069
1056
 
1070
1057
  // ---- prose styleguide ---------------------------------------------------
1071
- s.tool(
1072
- "setup_prose_styleguide_config",
1073
- "Create prose-styleguide.config.yaml at sync root or project root using language defaults plus optional explicit overrides.",
1074
- {
1075
- scope: z.enum(["sync_root", "project_root"]).optional().describe("Config write target scope. Defaults to project_root when project_id is supplied, otherwise sync_root."),
1076
- project_id: z.string().optional().describe("Project ID when writing project_root config (e.g. 'the-lamb' or 'universe-1/book-1')."),
1077
- language: z.enum(STYLEGUIDE_ENUMS.language).describe("Primary writing language. Seeds language-specific defaults."),
1078
- overrides: z.object({
1079
- spelling: z.enum(STYLEGUIDE_ENUMS.spelling).optional(),
1080
- quotation_style: z.enum(STYLEGUIDE_ENUMS.quotation_style).optional(),
1081
- quotation_style_nested: z.enum(STYLEGUIDE_ENUMS.quotation_style_nested).optional(),
1082
- em_dash_spacing: z.enum(STYLEGUIDE_ENUMS.em_dash_spacing).optional(),
1083
- ellipsis_style: z.enum(STYLEGUIDE_ENUMS.ellipsis_style).optional(),
1084
- abbreviation_periods: z.enum(STYLEGUIDE_ENUMS.abbreviation_periods).optional(),
1085
- oxford_comma: z.enum(STYLEGUIDE_ENUMS.oxford_comma).optional(),
1086
- numbers: z.enum(STYLEGUIDE_ENUMS.numbers).optional(),
1087
- date_format: z.enum(STYLEGUIDE_ENUMS.date_format).optional(),
1088
- time_format: z.enum(STYLEGUIDE_ENUMS.time_format).optional(),
1089
- tense: z.string().optional(),
1090
- pov: z.enum(STYLEGUIDE_ENUMS.pov).optional(),
1091
- dialogue_tags: z.enum(STYLEGUIDE_ENUMS.dialogue_tags).optional(),
1092
- sentence_fragments: z.enum(STYLEGUIDE_ENUMS.sentence_fragments).optional(),
1093
- }).optional().describe("Optional overrides layered on top of language defaults."),
1094
- voice_notes: z.string().optional().describe("Optional freeform voice notes to include in config."),
1095
- overwrite: z.boolean().optional().describe("If true, replaces an existing config file at the target location."),
1096
- },
1097
- async ({ scope, project_id, language, overrides = {}, voice_notes, overwrite = false }) => {
1098
- const resolvedScope = scope ?? (project_id ? "project_root" : "sync_root");
1099
-
1100
- if (project_id !== undefined) {
1101
- const projectIdCheck = validateProjectId(project_id);
1102
- if (!projectIdCheck.ok) {
1103
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1104
- }
1105
- }
1106
-
1107
- if (resolvedScope === "project_root" && !project_id) {
1108
- return errorResponse(
1109
- "PROJECT_ID_REQUIRED",
1110
- "project_id is required when scope=project_root."
1111
- );
1112
- }
1113
-
1114
- if (!SYNC_DIR_WRITABLE) {
1115
- return errorResponse(
1116
- "SYNC_DIR_NOT_WRITABLE",
1117
- "Cannot write styleguide config because WRITING_SYNC_DIR is not writable in this runtime.",
1118
- { sync_dir: SYNC_DIR_ABS }
1119
- );
1120
- }
1121
-
1122
- const targetPath = resolvedScope === "sync_root"
1123
- ? path.join(SYNC_DIR, STYLEGUIDE_CONFIG_BASENAME)
1124
- : path.join(resolveProjectRoot(project_id), STYLEGUIDE_CONFIG_BASENAME);
1125
-
1126
- if (!isPathCandidateInsideSyncDir(targetPath)) {
1127
- return errorResponse(
1128
- "INVALID_CONFIG_PATH",
1129
- "Resolved styleguide config path must be inside WRITING_SYNC_DIR.",
1130
- { target_path: path.resolve(targetPath), sync_dir: SYNC_DIR_ABS }
1131
- );
1132
- }
1133
-
1134
- if (fs.existsSync(targetPath) && !overwrite) {
1135
- return errorResponse(
1136
- "STYLEGUIDE_CONFIG_EXISTS",
1137
- "Styleguide config already exists at target path. Set overwrite=true to replace it.",
1138
- { target_path: path.resolve(targetPath) }
1139
- );
1140
- }
1141
-
1142
- const draft = buildStyleguideConfigDraft({
1143
- language,
1144
- overrides,
1145
- voice_notes,
1146
- });
1147
- if (!draft.ok) {
1148
- return errorResponse(
1149
- draft.error.code,
1150
- draft.error.message,
1151
- draft.error.details
1152
- );
1153
- }
1154
-
1155
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
1156
- fs.writeFileSync(targetPath, yaml.dump(draft.config, { lineWidth: 120 }), "utf8");
1157
-
1158
- return jsonResponse({
1159
- ok: true,
1160
- scope: resolvedScope,
1161
- file_path: path.resolve(targetPath),
1162
- config: draft.config,
1163
- inferred_defaults: draft.inferred_defaults,
1164
- warnings: draft.warnings,
1165
- next_step: "Config created. Call update_prose_styleguide_config to apply field updates.",
1166
- });
1167
- }
1168
- );
1169
-
1170
- s.tool(
1171
- "get_prose_styleguide_config",
1172
- "Resolve prose-styleguide.config.yaml with cascading precedence (sync root, then universe root, then project root). Applies language-derived defaults and nested quotation defaults when omitted.",
1173
- {
1174
- project_id: z.string().optional().describe("Optional project ID for project-scoped resolution (e.g. 'the-lamb' or 'universe-1/book-1')."),
1175
- },
1176
- async ({ project_id }) => {
1177
- if (project_id !== undefined) {
1178
- const projectIdCheck = validateProjectId(project_id);
1179
- if (!projectIdCheck.ok) {
1180
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1181
- }
1182
- }
1183
-
1184
- const resolved = resolveStyleguideConfig({
1185
- syncDir: SYNC_DIR,
1186
- projectId: project_id,
1187
- });
1188
-
1189
- if (!resolved.ok) {
1190
- return errorResponse(
1191
- resolved.error.code,
1192
- resolved.error.message,
1193
- resolved.error.details
1194
- );
1195
- }
1196
-
1197
- return jsonResponse({
1198
- ok: true,
1199
- styleguide: resolved,
1200
- next_step: resolved.setup_required
1201
- ? "No prose-styleguide.config.yaml was found. Call setup_prose_styleguide_config (with language e.g. 'en') to create one at sync root or project root."
1202
- : "Config resolved successfully.",
1203
- });
1204
- }
1205
- );
1206
-
1207
- s.tool(
1208
- "summarize_prose_styleguide_config",
1209
- "Summarize the currently resolved prose styleguide config in plain language for review or confirmation.",
1210
- {
1211
- project_id: z.string().optional().describe("Optional project ID for project-scoped resolution (e.g. 'the-lamb' or 'universe-1/book-1')."),
1212
- },
1213
- async ({ project_id }) => {
1214
- if (project_id !== undefined) {
1215
- const projectIdCheck = validateProjectId(project_id);
1216
- if (!projectIdCheck.ok) {
1217
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1218
- }
1219
- }
1220
-
1221
- const resolved = resolveStyleguideConfig({
1222
- syncDir: SYNC_DIR,
1223
- projectId: project_id,
1224
- });
1225
- if (!resolved.ok) {
1226
- return errorResponse(
1227
- resolved.error.code,
1228
- resolved.error.message,
1229
- resolved.error.details
1230
- );
1231
- }
1232
- if (resolved.setup_required || !resolved.resolved_config) {
1233
- return errorResponse(
1234
- "STYLEGUIDE_CONFIG_REQUIRED",
1235
- "Cannot summarize prose styleguide config before prose-styleguide.config.yaml is set up.",
1236
- {
1237
- project_id: project_id ?? null,
1238
- next_step: "Run setup_prose_styleguide_config or bootstrap_prose_styleguide_config.",
1239
- }
1240
- );
1241
- }
1242
-
1243
- const summary = summarizeStyleguideConfig({
1244
- resolvedConfig: resolved.resolved_config,
1245
- inferredDefaults: resolved.inferred_defaults,
1246
- });
1247
- if (!summary.ok) {
1248
- return errorResponse(summary.error.code, summary.error.message);
1249
- }
1250
-
1251
- return jsonResponse({
1252
- ok: true,
1253
- project_id: project_id ?? null,
1254
- summary_text: summary.summary_text,
1255
- summary_lines: summary.summary_lines,
1256
- styleguide: resolved,
1257
- });
1258
- }
1259
- );
1260
-
1261
- s.tool(
1262
- "bootstrap_prose_styleguide_config",
1263
- "Detect dominant prose conventions from existing scenes and suggest initial prose-styleguide config values.",
1264
- {
1265
- project_id: z.string().describe("Project ID to analyze (e.g. 'the-lamb' or 'universe-1/book-1')."),
1266
- scene_ids: z.array(z.string()).optional().describe("Optional scene_id allowlist to analyze."),
1267
- part: z.number().int().optional().describe("Optional part filter."),
1268
- chapter: z.number().int().optional().describe("Optional chapter filter."),
1269
- max_scenes: z.number().int().positive().optional().describe("Maximum number of scenes to analyze (default: 50)."),
1270
- min_agreement: z.number().min(0).max(1).optional().describe("Minimum agreement ratio for suggested fields (default: 0.6)."),
1271
- min_evidence: z.number().int().positive().optional().describe("Minimum number of observed scenes per field before suggesting it (default: 3)."),
1272
- include_scene_signals: z.boolean().optional().describe("If true, include per-scene detected signals in the response."),
1273
- },
1274
- async ({
1275
- project_id,
1276
- scene_ids,
1277
- part,
1278
- chapter,
1279
- max_scenes = 50,
1280
- min_agreement = 0.6,
1281
- min_evidence = 3,
1282
- include_scene_signals = false,
1283
- }) => {
1284
- const projectIdCheck = validateProjectId(project_id);
1285
- if (!projectIdCheck.ok) {
1286
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1287
- }
1288
-
1289
- const targetResolution = resolveBatchTargetScenes(db, {
1290
- projectId: project_id,
1291
- sceneIds: scene_ids,
1292
- part,
1293
- chapter,
1294
- onlyStale: false,
1295
- });
1296
- if (!targetResolution.ok) {
1297
- return errorResponse(targetResolution.code, targetResolution.message, targetResolution.details);
1298
- }
1299
-
1300
- const targetScenes = targetResolution.rows;
1301
- if (targetScenes.length === 0) {
1302
- return errorResponse(
1303
- "NOT_FOUND",
1304
- `No scenes were found for project '${project_id}' with the requested filters.`,
1305
- { project_id, scene_ids: scene_ids ?? null, part: part ?? null, chapter: chapter ?? null }
1306
- );
1307
- }
1308
-
1309
- if (targetScenes.length > max_scenes) {
1310
- return errorResponse(
1311
- "VALIDATION_ERROR",
1312
- `Matched ${targetScenes.length} scenes, which exceeds max_scenes=${max_scenes}.`,
1313
- {
1314
- matched_scenes: targetScenes.length,
1315
- max_scenes,
1316
- project_id,
1317
- next_step: maxScenesNextStep(targetScenes.length),
1318
- }
1319
- );
1320
- }
1321
-
1322
- const sceneSignals = [];
1323
- let unreadableScenes = 0;
1324
-
1325
- for (const scene of targetScenes) {
1326
- try {
1327
- const raw = fs.readFileSync(scene.file_path, "utf8");
1328
- const prose = matter(raw).content;
1329
- sceneSignals.push({
1330
- scene_id: scene.scene_id,
1331
- observed: detectStyleguideSignals(prose),
1332
- });
1333
- } catch {
1334
- unreadableScenes += 1;
1335
- sceneSignals.push({
1336
- scene_id: scene.scene_id,
1337
- observed: {},
1338
- });
1339
- }
1340
- }
1341
-
1342
- const suggestedConfig = suggestStyleguideUpdatesFromScenes({
1343
- sceneAnalyses: sceneSignals,
1344
- resolvedConfig: null,
1345
- minAgreement: min_agreement,
1346
- minEvidence: min_evidence,
1347
- });
1348
-
1349
- return jsonResponse({
1350
- ok: true,
1351
- project_id,
1352
- checked_scenes: sceneSignals.length,
1353
- unreadable_scenes: unreadableScenes,
1354
- suggested_config: suggestedConfig,
1355
- next_step: `To apply: (1) If no project-scoped config exists yet, call setup_prose_styleguide_config first with scope=project_root, project_id=${project_id}, and language (e.g. 'en'). (2) Then call update_prose_styleguide_config with the fields from suggested_config you want to apply.`,
1356
- scene_signals: include_scene_signals ? sceneSignals : undefined,
1357
- });
1358
- }
1359
- );
1360
-
1361
- s.tool(
1362
- "update_prose_styleguide_config",
1363
- "Update an existing prose-styleguide.config.yaml at sync-root or project-root scope by writing only explicit field changes.",
1364
- {
1365
- scope: z.enum(["sync_root", "project_root"]).describe("Config scope to update."),
1366
- project_id: z.string().optional().describe("Project ID when updating project_root config (e.g. 'the-lamb' or 'universe-1/book-1')."),
1367
- updates: z.object({
1368
- language: z.enum(STYLEGUIDE_ENUMS.language).optional(),
1369
- spelling: z.enum(STYLEGUIDE_ENUMS.spelling).optional(),
1370
- quotation_style: z.enum(STYLEGUIDE_ENUMS.quotation_style).optional(),
1371
- quotation_style_nested: z.enum(STYLEGUIDE_ENUMS.quotation_style_nested).optional(),
1372
- em_dash_spacing: z.enum(STYLEGUIDE_ENUMS.em_dash_spacing).optional(),
1373
- ellipsis_style: z.enum(STYLEGUIDE_ENUMS.ellipsis_style).optional(),
1374
- abbreviation_periods: z.enum(STYLEGUIDE_ENUMS.abbreviation_periods).optional(),
1375
- oxford_comma: z.enum(STYLEGUIDE_ENUMS.oxford_comma).optional(),
1376
- numbers: z.enum(STYLEGUIDE_ENUMS.numbers).optional(),
1377
- date_format: z.enum(STYLEGUIDE_ENUMS.date_format).optional(),
1378
- time_format: z.enum(STYLEGUIDE_ENUMS.time_format).optional(),
1379
- tense: z.string().optional(),
1380
- pov: z.enum(STYLEGUIDE_ENUMS.pov).optional(),
1381
- dialogue_tags: z.enum(STYLEGUIDE_ENUMS.dialogue_tags).optional(),
1382
- sentence_fragments: z.enum(STYLEGUIDE_ENUMS.sentence_fragments).optional(),
1383
- voice_notes: z.string().optional(),
1384
- }).strict().describe("Explicit config field changes to write at the selected scope."),
1385
- },
1386
- async ({ scope, project_id, updates }) => {
1387
- if (project_id !== undefined) {
1388
- const projectIdCheck = validateProjectId(project_id);
1389
- if (!projectIdCheck.ok) {
1390
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1391
- }
1392
- }
1393
-
1394
- if (scope === "project_root" && !project_id) {
1395
- return errorResponse(
1396
- "PROJECT_ID_REQUIRED",
1397
- "project_id is required when scope=project_root."
1398
- );
1399
- }
1400
-
1401
- if (!SYNC_DIR_WRITABLE) {
1402
- return errorResponse(
1403
- "SYNC_DIR_NOT_WRITABLE",
1404
- "Cannot update styleguide config because WRITING_SYNC_DIR is not writable in this runtime.",
1405
- { sync_dir: SYNC_DIR_ABS }
1406
- );
1407
- }
1408
-
1409
- const updated = updateStyleguideConfig({
1410
- syncDir: SYNC_DIR,
1411
- scope,
1412
- projectId: project_id,
1413
- updates,
1414
- });
1415
- if (!updated.ok) {
1416
- return errorResponse(
1417
- updated.error.code,
1418
- updated.error.message,
1419
- updated.error.details
1420
- );
1421
- }
1422
-
1423
- return jsonResponse({
1424
- ok: true,
1425
- scope: updated.scope,
1426
- project_id: updated.project_id,
1427
- file_path: path.resolve(updated.file_path),
1428
- config: updated.config,
1429
- changed_fields: updated.changed_fields,
1430
- noop: Boolean(updated.noop),
1431
- message: updated.message,
1432
- warnings: updated.warnings,
1433
- });
1434
- }
1435
- );
1436
-
1437
- s.tool(
1438
- "preview_prose_styleguide_config_update",
1439
- "Preview how explicit updates would change an existing prose-styleguide.config.yaml without writing any files.",
1440
- {
1441
- scope: z.enum(["sync_root", "project_root"]).describe("Config scope to preview updates for."),
1442
- project_id: z.string().optional().describe("Project ID when previewing project_root config updates (e.g. 'the-lamb' or 'universe-1/book-1')."),
1443
- updates: z.object({
1444
- language: z.enum(STYLEGUIDE_ENUMS.language).optional(),
1445
- spelling: z.enum(STYLEGUIDE_ENUMS.spelling).optional(),
1446
- quotation_style: z.enum(STYLEGUIDE_ENUMS.quotation_style).optional(),
1447
- quotation_style_nested: z.enum(STYLEGUIDE_ENUMS.quotation_style_nested).optional(),
1448
- em_dash_spacing: z.enum(STYLEGUIDE_ENUMS.em_dash_spacing).optional(),
1449
- ellipsis_style: z.enum(STYLEGUIDE_ENUMS.ellipsis_style).optional(),
1450
- abbreviation_periods: z.enum(STYLEGUIDE_ENUMS.abbreviation_periods).optional(),
1451
- oxford_comma: z.enum(STYLEGUIDE_ENUMS.oxford_comma).optional(),
1452
- numbers: z.enum(STYLEGUIDE_ENUMS.numbers).optional(),
1453
- date_format: z.enum(STYLEGUIDE_ENUMS.date_format).optional(),
1454
- time_format: z.enum(STYLEGUIDE_ENUMS.time_format).optional(),
1455
- tense: z.string().optional(),
1456
- pov: z.enum(STYLEGUIDE_ENUMS.pov).optional(),
1457
- dialogue_tags: z.enum(STYLEGUIDE_ENUMS.dialogue_tags).optional(),
1458
- sentence_fragments: z.enum(STYLEGUIDE_ENUMS.sentence_fragments).optional(),
1459
- voice_notes: z.string().optional(),
1460
- }).strict().describe("Explicit config field changes to preview at the selected scope."),
1461
- },
1462
- async ({ scope, project_id, updates }) => {
1463
- if (project_id !== undefined) {
1464
- const projectIdCheck = validateProjectId(project_id);
1465
- if (!projectIdCheck.ok) {
1466
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1467
- }
1468
- }
1469
-
1470
- if (scope === "project_root" && !project_id) {
1471
- return errorResponse(
1472
- "PROJECT_ID_REQUIRED",
1473
- "project_id is required when scope=project_root."
1474
- );
1475
- }
1476
-
1477
- const preview = previewStyleguideConfigUpdate({
1478
- syncDir: SYNC_DIR,
1479
- scope,
1480
- projectId: project_id,
1481
- updates,
1482
- });
1483
- if (!preview.ok) {
1484
- return errorResponse(
1485
- preview.error.code,
1486
- preview.error.message,
1487
- preview.error.details
1488
- );
1489
- }
1490
-
1491
- return jsonResponse({
1492
- ok: true,
1493
- scope: preview.scope,
1494
- project_id: preview.project_id,
1495
- file_path: path.resolve(preview.file_path),
1496
- current_config: preview.current_config,
1497
- next_config: preview.config,
1498
- changed_fields: preview.changed_fields,
1499
- noop: preview.changed_fields.length === 0,
1500
- message: preview.changed_fields.length === 0
1501
- ? "No changes detected for requested styleguide updates."
1502
- : "Preview generated.",
1503
- warnings: preview.warnings,
1504
- });
1505
- }
1506
- );
1507
-
1508
- s.tool(
1509
- "check_prose_styleguide_drift",
1510
- "Detect styleguide drift by comparing declared config conventions against observed signals in scene prose.",
1511
- {
1512
- project_id: z.string().describe("Project ID to analyze (e.g. 'the-lamb' or 'universe-1/book-1')."),
1513
- scene_ids: z.array(z.string()).optional().describe("Optional scene_id allowlist to analyze."),
1514
- part: z.number().int().optional().describe("Optional part filter."),
1515
- chapter: z.number().int().optional().describe("Optional chapter filter."),
1516
- max_scenes: z.number().int().positive().optional().describe("Maximum number of scenes to analyze (default: 50)."),
1517
- min_agreement: z.number().min(0).max(1).optional().describe("Minimum agreement ratio for suggested updates (default: 0.6)."),
1518
- include_clean_scenes: z.boolean().optional().describe("If true, include scenes with no detected drift in scene_results."),
1519
- },
1520
- async ({
1521
- project_id,
1522
- scene_ids,
1523
- part,
1524
- chapter,
1525
- max_scenes = 50,
1526
- min_agreement = 0.6,
1527
- include_clean_scenes = false,
1528
- }) => {
1529
- const projectIdCheck = validateProjectId(project_id);
1530
- if (!projectIdCheck.ok) {
1531
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1532
- }
1533
-
1534
- const resolved = resolveStyleguideConfig({
1535
- syncDir: SYNC_DIR,
1536
- projectId: project_id,
1537
- });
1538
- if (!resolved.ok) {
1539
- return errorResponse(
1540
- resolved.error.code,
1541
- resolved.error.message,
1542
- resolved.error.details
1543
- );
1544
- }
1545
- if (resolved.setup_required || !resolved.resolved_config) {
1546
- return errorResponse(
1547
- "STYLEGUIDE_CONFIG_REQUIRED",
1548
- "Cannot check prose styleguide drift before prose-styleguide.config.yaml is set up.",
1549
- {
1550
- project_id,
1551
- next_step: "Run setup_prose_styleguide_config or bootstrap_prose_styleguide_config.",
1552
- }
1553
- );
1554
- }
1555
-
1556
- const targetResolution = resolveBatchTargetScenes(db, {
1557
- projectId: project_id,
1558
- sceneIds: scene_ids,
1559
- part,
1560
- chapter,
1561
- onlyStale: false,
1562
- });
1563
- if (!targetResolution.ok) {
1564
- return errorResponse(targetResolution.code, targetResolution.message, targetResolution.details);
1565
- }
1566
-
1567
- const targetScenes = targetResolution.rows;
1568
- if (targetScenes.length > max_scenes) {
1569
- return errorResponse(
1570
- "VALIDATION_ERROR",
1571
- `Matched ${targetScenes.length} scenes, which exceeds max_scenes=${max_scenes}.`,
1572
- {
1573
- matched_scenes: targetScenes.length,
1574
- max_scenes,
1575
- project_id,
1576
- next_step: maxScenesNextStep(targetScenes.length),
1577
- }
1578
- );
1579
- }
1580
-
1581
- const sceneAnalyses = [];
1582
- for (const scene of targetScenes) {
1583
- let prose;
1584
- try {
1585
- const raw = fs.readFileSync(scene.file_path, "utf8");
1586
- prose = matter(raw).content;
1587
- } catch {
1588
- sceneAnalyses.push({
1589
- scene_id: scene.scene_id,
1590
- observed: {},
1591
- drift: [{ field: "scene_file", declared: "readable", observed: "unreadable" }],
1592
- });
1593
- continue;
1594
- }
1595
-
1596
- const analysis = analyzeSceneStyleguideDrift({
1597
- prose,
1598
- resolvedConfig: resolved.resolved_config,
1599
- });
1600
- sceneAnalyses.push({
1601
- scene_id: scene.scene_id,
1602
- observed: analysis.observed,
1603
- drift: analysis.drift,
1604
- });
1605
- }
1606
-
1607
- const suggestedUpdates = suggestStyleguideUpdatesFromScenes({
1608
- sceneAnalyses,
1609
- resolvedConfig: resolved.resolved_config,
1610
- minAgreement: min_agreement,
1611
- });
1612
-
1613
- const filteredScenes = include_clean_scenes
1614
- ? sceneAnalyses
1615
- : sceneAnalyses.filter((scene) => scene.drift.length > 0);
1616
-
1617
- const driftByField = {};
1618
- for (const scene of sceneAnalyses) {
1619
- for (const entry of scene.drift) {
1620
- driftByField[entry.field] = (driftByField[entry.field] ?? 0) + 1;
1621
- }
1622
- }
1623
-
1624
- return jsonResponse({
1625
- ok: true,
1626
- project_id,
1627
- checked_scenes: sceneAnalyses.length,
1628
- scenes_with_drift: sceneAnalyses.filter((scene) => scene.drift.length > 0).length,
1629
- drift_by_field: driftByField,
1630
- scene_results: filteredScenes,
1631
- suggested_updates: suggestedUpdates,
1632
- });
1633
- }
1634
- );
1635
-
1636
- s.tool(
1637
- "setup_prose_styleguide_skill",
1638
- "Generate skills/prose-styleguide.md from the resolved prose styleguide config and universal craft rules.",
1639
- {
1640
- project_id: z.string().optional().describe("Optional project ID for scoped config resolution (e.g. 'the-lamb' or 'universe-1/book-1')."),
1641
- overwrite: z.boolean().optional().describe("If true, replaces an existing skills/prose-styleguide.md file."),
1642
- },
1643
- async ({ project_id, overwrite = false }) => {
1644
- if (project_id !== undefined) {
1645
- const projectIdCheck = validateProjectId(project_id);
1646
- if (!projectIdCheck.ok) {
1647
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1648
- }
1649
- }
1650
-
1651
- if (!SYNC_DIR_WRITABLE) {
1652
- return errorResponse(
1653
- "SYNC_DIR_NOT_WRITABLE",
1654
- "Cannot write prose styleguide skill because WRITING_SYNC_DIR is not writable in this runtime.",
1655
- { sync_dir: SYNC_DIR_ABS }
1656
- );
1657
- }
1658
-
1659
- const resolved = resolveStyleguideConfig({
1660
- syncDir: SYNC_DIR,
1661
- projectId: project_id,
1662
- });
1663
- if (!resolved.ok) {
1664
- return errorResponse(
1665
- resolved.error.code,
1666
- resolved.error.message,
1667
- resolved.error.details
1668
- );
1669
- }
1670
- if (resolved.setup_required || !resolved.resolved_config) {
1671
- return errorResponse(
1672
- "STYLEGUIDE_CONFIG_REQUIRED",
1673
- "Cannot generate prose-styleguide.md before prose-styleguide.config.yaml is set up.",
1674
- {
1675
- project_id: project_id ?? null,
1676
- next_step: "Run setup_prose_styleguide_config or bootstrap_prose_styleguide_config first.",
1677
- }
1678
- );
1679
- }
1680
-
1681
- const skillPath = path.join(SYNC_DIR, PROSE_STYLEGUIDE_SKILL_DIRNAME, PROSE_STYLEGUIDE_SKILL_BASENAME);
1682
- if (!isPathCandidateInsideSyncDir(skillPath)) {
1683
- return errorResponse(
1684
- "INVALID_SKILL_PATH",
1685
- "Resolved prose styleguide skill path must be inside WRITING_SYNC_DIR.",
1686
- { target_path: path.resolve(skillPath), sync_dir: SYNC_DIR_ABS }
1687
- );
1688
- }
1689
-
1690
- if (fs.existsSync(skillPath) && !overwrite) {
1691
- return errorResponse(
1692
- "STYLEGUIDE_SKILL_EXISTS",
1693
- "skills/prose-styleguide.md already exists. Set overwrite=true to replace it.",
1694
- { target_path: path.resolve(skillPath) }
1695
- );
1696
- }
1697
-
1698
- const generated = buildProseStyleguideSkill({
1699
- resolvedConfig: resolved.resolved_config,
1700
- sources: resolved.sources,
1701
- projectId: project_id ?? null,
1702
- });
1703
- if (!generated.ok) {
1704
- return errorResponse(generated.error.code, generated.error.message);
1705
- }
1706
-
1707
- fs.mkdirSync(path.dirname(skillPath), { recursive: true });
1708
- fs.writeFileSync(skillPath, generated.markdown, "utf8");
1709
-
1710
- return jsonResponse({
1711
- ok: true,
1712
- file_path: path.resolve(skillPath),
1713
- project_id: project_id ?? null,
1714
- injected_rules: generated.injected_rules,
1715
- source_count: resolved.sources.length,
1716
- });
1717
- }
1718
- );
1719
-
1720
- // ---- PHASE 3: Prose Editing (git-backed) --------------------------------
1721
-
1722
- // ---- propose_edit --------------------------------------------------------
1723
- s.tool(
1724
- "propose_edit",
1725
- "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.",
1726
- {
1727
- scene_id: z.string().describe("The scene_id to revise (e.g. 'sc-011-sebastian')."),
1728
- instruction: z.string().describe("A brief instruction for the edit (e.g. 'Tighten the opening paragraph'). Used in the git commit message."),
1729
- revised_prose: z.string().describe("The complete revised prose text for the scene."),
1730
- },
1731
- async ({ scene_id, instruction, revised_prose }) => {
1732
- if (!GIT_ENABLED) {
1733
- return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported. Ensure git is installed and the sync directory is writable.");
1734
- }
1735
-
1736
- const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
1737
- if (!scene) {
1738
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Hint: call find_scenes to get valid scene IDs.`);
1739
- }
1740
-
1741
- try {
1742
- // Read current prose
1743
- const raw = fs.readFileSync(scene.file_path, "utf8");
1744
- const { data: metadata, content: currentProse } = matter(raw);
1745
-
1746
- // Generate a simple diff representation
1747
- const currentLines = currentProse.trim().split("\n");
1748
- const revisedLines = revised_prose.trim().split("\n");
1749
- const diffLines = [];
1750
- const maxLines = Math.max(currentLines.length, revisedLines.length);
1751
-
1752
- // Simple line-by-line diff
1753
- for (let i = 0; i < Math.min(3, maxLines); i++) {
1754
- const curr = currentLines[i] || "(removed)";
1755
- const rev = revisedLines[i] || "(removed)";
1756
- if (curr !== rev) {
1757
- diffLines.push(`- ${curr.substring(0, 80)}`);
1758
- diffLines.push(`+ ${rev.substring(0, 80)}`);
1759
- }
1760
- }
1761
- if (maxLines > 3) {
1762
- diffLines.push(`... (${maxLines - 3} more lines)`);
1763
- }
1764
-
1765
- const proposalId = generateProposalId();
1766
- pendingProposals.set(proposalId, {
1767
- scene_id,
1768
- scene_file_path: scene.file_path,
1769
- instruction,
1770
- revised_prose,
1771
- original_prose: currentProse,
1772
- metadata,
1773
- created_at: new Date().toISOString(),
1774
- });
1775
-
1776
- const summary = {
1777
- proposal_id: proposalId,
1778
- scene_id,
1779
- instruction,
1780
- diff_preview: diffLines.join("\n"),
1781
- note: "Review the diff above. Call commit_edit with this proposal_id to apply the change.",
1782
- };
1783
-
1784
- return jsonResponse(summary);
1785
- } catch (err) {
1786
- if (err.code === "ENOENT") {
1787
- return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path.`, { indexed_path: scene.file_path });
1788
- }
1789
- return errorResponse("IO_ERROR", `Failed to read scene file: ${err.message}`);
1790
- }
1791
- }
1792
- );
1793
-
1794
- // ---- commit_edit ---------------------------------------------------------
1795
- s.tool(
1796
- "commit_edit",
1797
- "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.",
1798
- {
1799
- scene_id: z.string().describe("The scene_id being revised."),
1800
- proposal_id: z.string().describe("The proposal_id returned by propose_edit."),
1801
- },
1802
- async ({ scene_id, proposal_id }) => {
1803
- if (!GIT_ENABLED) {
1804
- return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported.");
1805
- }
1806
-
1807
- const proposal = pendingProposals.get(proposal_id);
1808
- if (!proposal) {
1809
- return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has expired. Hint: call propose_edit again to create a fresh proposal_id.`);
1810
- }
1811
-
1812
- if (proposal.scene_id !== scene_id) {
1813
- return errorResponse("INVALID_EDIT", `Proposal '${proposal_id}' is for scene '${proposal.scene_id}', not '${scene_id}'.`);
1814
- }
1815
-
1816
- try {
1817
- const proseWriteDiagnostics = getFileWriteDiagnostics(proposal.scene_file_path);
1818
- if (proseWriteDiagnostics.stat_error_code === "EACCES" || proseWriteDiagnostics.stat_error_code === "EPERM") {
1819
- return errorResponse(
1820
- "PROSE_FILE_NOT_WRITABLE",
1821
- "Scene prose file cannot be accessed by the current runtime user.",
1822
- {
1823
- indexed_path: proposal.scene_file_path,
1824
- prose_write_diagnostics: proseWriteDiagnostics,
1825
- }
1826
- );
1827
- }
1828
-
1829
- if (proseWriteDiagnostics.stat_error_code && proseWriteDiagnostics.stat_error_code !== "ENOENT" && proseWriteDiagnostics.stat_error_code !== "ENOTDIR") {
1830
- return errorResponse(
1831
- "IO_ERROR",
1832
- "Failed to inspect scene prose path before writing.",
1833
- {
1834
- indexed_path: proposal.scene_file_path,
1835
- prose_write_diagnostics: proseWriteDiagnostics,
1836
- }
1837
- );
1838
- }
1839
-
1840
- if (!proseWriteDiagnostics.exists) {
1841
- return errorResponse("STALE_PATH", "Prose file not found at indexed path.", {
1842
- indexed_path: proposal.scene_file_path,
1843
- prose_write_diagnostics: proseWriteDiagnostics,
1844
- });
1845
- }
1846
-
1847
- if (!proseWriteDiagnostics.is_file) {
1848
- return errorResponse("INVALID_PROSE_PATH", "Indexed prose path is not a regular file.", {
1849
- indexed_path: proposal.scene_file_path,
1850
- prose_write_diagnostics: proseWriteDiagnostics,
1851
- });
1852
- }
1853
-
1854
- if (!proseWriteDiagnostics.writable) {
1855
- return errorResponse(
1856
- "PROSE_FILE_NOT_WRITABLE",
1857
- "Scene prose file is not writable by the current runtime user.",
1858
- {
1859
- indexed_path: proposal.scene_file_path,
1860
- prose_write_diagnostics: proseWriteDiagnostics,
1861
- }
1862
- );
1863
- }
1864
-
1865
- // Reconstruct file content, preserving frontmatter only if the original had it
1866
- const hasFrontmatter = proposal.metadata && Object.keys(proposal.metadata).length > 0;
1867
- const content = hasFrontmatter
1868
- ? `---\n${yaml.dump(proposal.metadata)}---\n\n${proposal.revised_prose}\n`
1869
- : `${proposal.revised_prose}\n`;
1870
-
1871
- // Create pre-edit snapshot (commits current state before overwriting)
1872
- const snapshot = createSnapshot(SYNC_DIR, proposal.scene_file_path, scene_id, proposal.instruction);
1873
-
1874
- // Write the revised prose to disk
1875
- fs.writeFileSync(proposal.scene_file_path, content, "utf8");
1876
-
1877
- // Re-index using canonical metadata (sidecar takes precedence over inline frontmatter)
1878
- const { meta: canonicalMeta } = readMeta(proposal.scene_file_path, SYNC_DIR, { writable: false });
1879
- const { content: newProse } = matter(content);
1880
- indexSceneFile(db, SYNC_DIR, proposal.scene_file_path, canonicalMeta, newProse);
1881
-
1882
- // Clean up the proposal
1883
- pendingProposals.delete(proposal_id);
1884
-
1885
- const result = {
1886
- ok: true,
1887
- scene_id,
1888
- proposal_id,
1889
- snapshot_commit: snapshot.commit_hash,
1890
- message: `Committed edit for scene '${scene_id}'${snapshot.commit_hash ? ` (snapshot: ${snapshot.commit_hash.substring(0, 7)})` : " (no changes to snapshot)"}`,
1891
- };
1892
-
1893
- return jsonResponse(result);
1894
- } catch (err) {
1895
- if (err.code === "ENOENT") {
1896
- return errorResponse("STALE_PATH", `Prose file not found at indexed path.`, { indexed_path: proposal.scene_file_path });
1897
- }
1898
- return errorResponse("IO_ERROR", `Failed to commit edit: ${err.message}`);
1899
- }
1900
- }
1901
- );
1902
-
1903
- // ---- discard_edit --------------------------------------------------------
1904
- s.tool(
1905
- "discard_edit",
1906
- "Discard a pending proposal without applying it. The proposal is deleted and the prose remains unchanged.",
1907
- {
1908
- proposal_id: z.string().describe("The proposal_id to discard (from propose_edit)."),
1909
- },
1910
- async ({ proposal_id }) => {
1911
- const proposal = pendingProposals.get(proposal_id);
1912
- if (!proposal) {
1913
- return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has already been discarded.`);
1914
- }
1915
-
1916
- pendingProposals.delete(proposal_id);
1917
- return jsonResponse({
1918
- ok: true,
1919
- proposal_id,
1920
- message: `Discarded proposal '${proposal_id}' for scene '${proposal.scene_id}'.`,
1921
- });
1922
- }
1923
- );
1924
-
1925
- // ---- snapshot_scene -------------------------------------------------------
1926
- s.tool(
1927
- "snapshot_scene",
1928
- "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.",
1929
- {
1930
- scene_id: z.string().describe("The scene_id to snapshot."),
1931
- project_id: z.string().describe("Project the scene belongs to."),
1932
- reason: z.string().describe("A brief reason for the snapshot (e.g. 'Character arc milestone reached')."),
1933
- },
1934
- async ({ scene_id, project_id, reason }) => {
1935
- if (!GIT_ENABLED) {
1936
- return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be created.");
1937
- }
1938
-
1939
- const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
1940
- .get(scene_id, project_id);
1941
- if (!scene) {
1942
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
1943
- }
1944
-
1945
- try {
1946
- const snapshot = createSnapshot(SYNC_DIR, scene.file_path, scene_id, reason);
1947
- if (!snapshot.commit_hash) {
1948
- return jsonResponse({
1949
- ok: true,
1950
- scene_id,
1951
- reason,
1952
- message: "No changes to snapshot.",
1953
- });
1954
- }
1955
-
1956
- return jsonResponse({
1957
- ok: true,
1958
- scene_id,
1959
- reason,
1960
- commit_hash: snapshot.commit_hash,
1961
- message: `Created snapshot for scene '${scene_id}': ${reason}`,
1962
- });
1963
- } catch (err) {
1964
- if (err.code === "ENOENT") {
1965
- return errorResponse("STALE_PATH", `Prose file not found at indexed path.`, { indexed_path: scene.file_path });
1966
- }
1967
- return errorResponse("IO_ERROR", `Failed to create snapshot: ${err.message}`);
1968
- }
1969
- }
1970
- );
1971
-
1972
- // ---- list_snapshots -------------------------------------------------------
1973
- s.tool(
1974
- "list_snapshots",
1975
- "List git commit history for a scene, with timestamps and commit messages. Use this to find commit hashes for get_scene_prose historical retrieval.",
1976
- {
1977
- scene_id: z.string().describe("The scene_id to list snapshots for."),
1978
- },
1979
- async ({ scene_id }) => {
1980
- if (!GIT_ENABLED) {
1981
- return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be retrieved.");
1982
- }
1983
-
1984
- const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
1985
- if (!scene) {
1986
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found.`);
1987
- }
1988
-
1989
- try {
1990
- const snapshots = listSnapshots(SYNC_DIR, scene.file_path);
1991
- if (!snapshots || snapshots.length === 0) {
1992
- return errorResponse("NO_RESULTS", `No snapshots found for scene '${scene_id}'. Try editing and committing the scene first.`);
1993
- }
1994
-
1995
- return jsonResponse({
1996
- scene_id,
1997
- snapshots: snapshots.map(s => ({
1998
- commit_hash: s.commit_hash,
1999
- short_hash: s.commit_hash.substring(0, 7),
2000
- timestamp: s.timestamp,
2001
- message: s.message,
2002
- })),
2003
- note: "Use the commit_hash values with get_scene_prose(scene_id, commit) to retrieve a past version.",
2004
- });
2005
- } catch (err) {
2006
- return errorResponse("IO_ERROR", `Failed to list snapshots: ${err.message}`);
2007
- }
2008
- }
2009
- );
2010
-
2011
1058
  return s;
2012
1059
  }
2013
1060