@hanna84/mcp-writing 1.14.0 → 1.16.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 +20 -0
- package/index.js +223 -1
- package/package.json +2 -1
- package/review-bundles.js +705 -0
- package/scripts/manual/run_create_review_bundle.js +64 -0
- package/scripts/manual/run_mcp_and_review.js +78 -0
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.16.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v1.15.0...v1.16.0)
|
|
9
|
+
|
|
10
|
+
- feat: add create_review_bundle markdown artifact generation [`#74`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/74)
|
|
12
|
+
|
|
13
|
+
#### [v1.15.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v1.14.0...v1.15.0)
|
|
15
|
+
|
|
16
|
+
> 24 April 2026
|
|
17
|
+
|
|
18
|
+
- feat: add review bundle preview planner tool [`#73`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/73)
|
|
20
|
+
- Release 1.15.0 [`0ec37c6`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/0ec37c69aa16fb1f4f28e01fae9b864158f1b85e)
|
|
22
|
+
|
|
7
23
|
#### [v1.14.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v1.13.2...v1.14.0)
|
|
9
25
|
|
|
26
|
+
> 24 April 2026
|
|
27
|
+
|
|
10
28
|
- feat(scrivener-direct): graduate from beta to stable [`#72`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/72)
|
|
30
|
+
- Release 1.14.0 [`4baf0c3`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/4baf0c38eed39337a58c8503d382dc1db6987c6a)
|
|
12
32
|
|
|
13
33
|
#### [v1.13.2](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v1.13.1...v1.13.2)
|
package/index.js
CHANGED
|
@@ -13,10 +13,17 @@ import yaml from "js-yaml";
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { openDb } from "./db.js";
|
|
15
15
|
import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, getFileWriteDiagnostics, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
|
|
16
|
-
import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
|
|
16
|
+
import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit, getHeadCommitHash } from "./git.js";
|
|
17
17
|
import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
|
|
18
18
|
import { importScrivenerSync, validateProjectId } from "./importer.js";
|
|
19
19
|
import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
|
|
20
|
+
import {
|
|
21
|
+
REVIEW_BUNDLE_PROFILES,
|
|
22
|
+
REVIEW_BUNDLE_STRICTNESS,
|
|
23
|
+
ReviewBundlePlanError,
|
|
24
|
+
buildReviewBundlePlan,
|
|
25
|
+
createReviewBundleArtifacts,
|
|
26
|
+
} from "./review-bundles.js";
|
|
20
27
|
|
|
21
28
|
const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
|
|
22
29
|
const DB_PATH = process.env.DB_PATH ?? "./writing.db";
|
|
@@ -44,6 +51,47 @@ function isPathInsideSyncDir(candidatePath) {
|
|
|
44
51
|
return !(rel.startsWith("..") || path.isAbsolute(rel));
|
|
45
52
|
}
|
|
46
53
|
|
|
54
|
+
function resolveOutputDirWithinSync(outputDir) {
|
|
55
|
+
let resolvedOutputDir = path.resolve(outputDir);
|
|
56
|
+
let existingAncestor = resolvedOutputDir;
|
|
57
|
+
|
|
58
|
+
while (!fs.existsSync(existingAncestor)) {
|
|
59
|
+
const parentDir = path.dirname(existingAncestor);
|
|
60
|
+
if (parentDir === existingAncestor) {
|
|
61
|
+
throw new ReviewBundlePlanError(
|
|
62
|
+
"INVALID_OUTPUT_DIR",
|
|
63
|
+
"output_dir must be inside WRITING_SYNC_DIR.",
|
|
64
|
+
{ output_dir: resolvedOutputDir, sync_dir: SYNC_DIR_ABS }
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
existingAncestor = parentDir;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let realExistingAncestor;
|
|
71
|
+
try {
|
|
72
|
+
realExistingAncestor = fs.realpathSync.native(existingAncestor);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
throw new ReviewBundlePlanError(
|
|
75
|
+
"INVALID_OUTPUT_DIR",
|
|
76
|
+
"output_dir ancestor could not be resolved: path may be inaccessible.",
|
|
77
|
+
{ output_dir: outputDir, existing_ancestor: existingAncestor, cause: err.message }
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const relativeFromAncestor = path.relative(existingAncestor, resolvedOutputDir);
|
|
81
|
+
resolvedOutputDir = path.resolve(realExistingAncestor, relativeFromAncestor);
|
|
82
|
+
|
|
83
|
+
const relativeToSyncDir = path.relative(SYNC_DIR_REAL, resolvedOutputDir);
|
|
84
|
+
if (relativeToSyncDir.startsWith("..") || path.isAbsolute(relativeToSyncDir)) {
|
|
85
|
+
throw new ReviewBundlePlanError(
|
|
86
|
+
"INVALID_OUTPUT_DIR",
|
|
87
|
+
"output_dir must be inside WRITING_SYNC_DIR.",
|
|
88
|
+
{ output_dir: resolvedOutputDir, sync_dir: SYNC_DIR_ABS }
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { resolvedOutputDir, relativeToSyncDir };
|
|
93
|
+
}
|
|
94
|
+
|
|
47
95
|
function parsePositiveIntEnv(rawValue, defaultValue) {
|
|
48
96
|
const parsed = parseInt(rawValue ?? String(defaultValue), 10);
|
|
49
97
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
@@ -1298,6 +1346,180 @@ function createMcpServer() {
|
|
|
1298
1346
|
}
|
|
1299
1347
|
);
|
|
1300
1348
|
|
|
1349
|
+
// ---- preview_review_bundle ----------------------------------------------
|
|
1350
|
+
s.tool(
|
|
1351
|
+
"preview_review_bundle",
|
|
1352
|
+
"Dry-run planning tool for review bundles. Resolves scene scope, deterministic ordering, warnings, and planned output filenames without writing files. Note: include_scene_ids/include_metadata_sidebar/include_paragraph_anchors are advisory placeholders in Phase 4A.1 and do not alter planning semantics yet.",
|
|
1353
|
+
{
|
|
1354
|
+
project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
|
|
1355
|
+
profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion or editor_detailed."),
|
|
1356
|
+
part: z.number().int().optional().describe("Optional part filter."),
|
|
1357
|
+
chapter: z.number().int().optional().describe("Optional chapter filter."),
|
|
1358
|
+
tag: z.string().optional().describe("Optional tag filter (exact match)."),
|
|
1359
|
+
scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
|
|
1360
|
+
strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
|
|
1361
|
+
include_scene_ids: z.boolean().optional().describe("Advisory placeholder for later rendering behavior (default true). Included in preview output options, but does not change planning results in Phase 4A.1."),
|
|
1362
|
+
include_metadata_sidebar: z.boolean().optional().describe("Advisory placeholder for later rendering behavior (default false). Included in preview output options, but does not change planning results in Phase 4A.1."),
|
|
1363
|
+
include_paragraph_anchors: z.boolean().optional().describe("Advisory placeholder for later rendering behavior (default false). Included in preview output options, but does not change planning results in Phase 4A.1."),
|
|
1364
|
+
bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in planned outputs)."),
|
|
1365
|
+
},
|
|
1366
|
+
async ({
|
|
1367
|
+
project_id,
|
|
1368
|
+
profile,
|
|
1369
|
+
part,
|
|
1370
|
+
chapter,
|
|
1371
|
+
tag,
|
|
1372
|
+
scene_ids,
|
|
1373
|
+
strictness = "warn",
|
|
1374
|
+
include_scene_ids = true,
|
|
1375
|
+
include_metadata_sidebar = false,
|
|
1376
|
+
include_paragraph_anchors = false,
|
|
1377
|
+
bundle_name,
|
|
1378
|
+
}) => {
|
|
1379
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1380
|
+
if (!projectIdCheck.ok) {
|
|
1381
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
try {
|
|
1385
|
+
const plan = buildReviewBundlePlan(db, {
|
|
1386
|
+
project_id,
|
|
1387
|
+
profile,
|
|
1388
|
+
part,
|
|
1389
|
+
chapter,
|
|
1390
|
+
tag,
|
|
1391
|
+
scene_ids,
|
|
1392
|
+
strictness,
|
|
1393
|
+
include_scene_ids,
|
|
1394
|
+
include_metadata_sidebar,
|
|
1395
|
+
include_paragraph_anchors,
|
|
1396
|
+
bundle_name,
|
|
1397
|
+
});
|
|
1398
|
+
return jsonResponse(plan);
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
if (error instanceof ReviewBundlePlanError) {
|
|
1401
|
+
return errorResponse(error.code, error.message, error.details);
|
|
1402
|
+
}
|
|
1403
|
+
return errorResponse(
|
|
1404
|
+
"PREVIEW_FAILED",
|
|
1405
|
+
error instanceof Error ? error.message : "Failed to generate review bundle preview."
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
);
|
|
1410
|
+
|
|
1411
|
+
// ---- create_review_bundle -----------------------------------------------
|
|
1412
|
+
s.tool(
|
|
1413
|
+
"create_review_bundle",
|
|
1414
|
+
"Generate markdown review bundle artifacts from planned scene scope. Writes files only under output_dir and returns manifest/provenance details.",
|
|
1415
|
+
{
|
|
1416
|
+
project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
|
|
1417
|
+
profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion or editor_detailed."),
|
|
1418
|
+
output_dir: z.string().describe("Directory path to write bundle artifacts into."),
|
|
1419
|
+
part: z.number().int().optional().describe("Optional part filter."),
|
|
1420
|
+
chapter: z.number().int().optional().describe("Optional chapter filter."),
|
|
1421
|
+
tag: z.string().optional().describe("Optional tag filter (exact match)."),
|
|
1422
|
+
scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
|
|
1423
|
+
strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
|
|
1424
|
+
include_scene_ids: z.boolean().optional().describe("Include scene IDs in markdown headings (default true)."),
|
|
1425
|
+
include_metadata_sidebar: z.boolean().optional().describe("Include metadata sidebar in markdown output (default false)."),
|
|
1426
|
+
include_paragraph_anchors: z.boolean().optional().describe("Include paragraph anchors in markdown output (default false)."),
|
|
1427
|
+
bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in filenames)."),
|
|
1428
|
+
source_commit: z.string().optional().describe("Optional explicit source commit for provenance. Defaults to current HEAD when available."),
|
|
1429
|
+
},
|
|
1430
|
+
async ({
|
|
1431
|
+
project_id,
|
|
1432
|
+
profile,
|
|
1433
|
+
output_dir,
|
|
1434
|
+
part,
|
|
1435
|
+
chapter,
|
|
1436
|
+
tag,
|
|
1437
|
+
scene_ids,
|
|
1438
|
+
strictness = "warn",
|
|
1439
|
+
include_scene_ids = true,
|
|
1440
|
+
include_metadata_sidebar = false,
|
|
1441
|
+
include_paragraph_anchors = false,
|
|
1442
|
+
bundle_name,
|
|
1443
|
+
source_commit,
|
|
1444
|
+
}) => {
|
|
1445
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1446
|
+
if (!projectIdCheck.ok) {
|
|
1447
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
try {
|
|
1451
|
+
const { resolvedOutputDir, relativeToSyncDir } = resolveOutputDirWithinSync(output_dir);
|
|
1452
|
+
const outputDirSegments = relativeToSyncDir
|
|
1453
|
+
.split(path.sep)
|
|
1454
|
+
.filter(Boolean)
|
|
1455
|
+
.map(segment => segment.toLowerCase());
|
|
1456
|
+
if (outputDirSegments.includes("scenes")) {
|
|
1457
|
+
return errorResponse(
|
|
1458
|
+
"INVALID_OUTPUT_DIR",
|
|
1459
|
+
"output_dir cannot be inside a scenes directory. Choose a dedicated export folder under WRITING_SYNC_DIR.",
|
|
1460
|
+
{ output_dir: resolvedOutputDir }
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const plan = buildReviewBundlePlan(db, {
|
|
1465
|
+
project_id,
|
|
1466
|
+
profile,
|
|
1467
|
+
part,
|
|
1468
|
+
chapter,
|
|
1469
|
+
tag,
|
|
1470
|
+
scene_ids,
|
|
1471
|
+
strictness,
|
|
1472
|
+
include_scene_ids,
|
|
1473
|
+
include_metadata_sidebar,
|
|
1474
|
+
include_paragraph_anchors,
|
|
1475
|
+
bundle_name,
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
if (!plan.strictness_result.can_proceed) {
|
|
1479
|
+
return errorResponse(
|
|
1480
|
+
"STRICTNESS_BLOCKED",
|
|
1481
|
+
"Bundle generation blocked by strictness policy.",
|
|
1482
|
+
{ strictness_result: plan.strictness_result, warning_summary: plan.warning_summary }
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const provenanceCommit = source_commit ?? (GIT_ENABLED ? getHeadCommitHash(SYNC_DIR) : null);
|
|
1487
|
+
const artifacts = createReviewBundleArtifacts(db, {
|
|
1488
|
+
plan,
|
|
1489
|
+
output_dir: resolvedOutputDir,
|
|
1490
|
+
source_commit: provenanceCommit,
|
|
1491
|
+
syncDir: SYNC_DIR_ABS,
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
return jsonResponse({
|
|
1495
|
+
ok: true,
|
|
1496
|
+
bundle_id: artifacts.bundle_id,
|
|
1497
|
+
output_paths: artifacts.output_paths,
|
|
1498
|
+
summary: {
|
|
1499
|
+
scene_count: plan.summary.scene_count,
|
|
1500
|
+
profile: plan.profile,
|
|
1501
|
+
applied_filters: plan.resolved_scope.filters,
|
|
1502
|
+
},
|
|
1503
|
+
warnings: plan.warnings,
|
|
1504
|
+
warning_summary: plan.warning_summary,
|
|
1505
|
+
provenance: {
|
|
1506
|
+
source_commit: provenanceCommit,
|
|
1507
|
+
generated_at: artifacts.generated_at,
|
|
1508
|
+
project_id: plan.resolved_scope.project_id,
|
|
1509
|
+
},
|
|
1510
|
+
});
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
if (error instanceof ReviewBundlePlanError) {
|
|
1513
|
+
return errorResponse(error.code, error.message, error.details);
|
|
1514
|
+
}
|
|
1515
|
+
return errorResponse(
|
|
1516
|
+
"CREATE_BUNDLE_FAILED",
|
|
1517
|
+
error instanceof Error ? error.message : "Failed to create review bundle artifacts."
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
);
|
|
1522
|
+
|
|
1301
1523
|
// ---- find_scenes ---------------------------------------------------------
|
|
1302
1524
|
s.tool(
|
|
1303
1525
|
"find_scenes",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"git.js",
|
|
15
15
|
"world-entity-templates.js",
|
|
16
16
|
"metadata-lint.js",
|
|
17
|
+
"review-bundles.js",
|
|
17
18
|
"scripts/",
|
|
18
19
|
"README.md",
|
|
19
20
|
"CHANGELOG.md"
|
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
|
|
5
|
+
const MAX_SORT_VALUE = Number.MAX_SAFE_INTEGER;
|
|
6
|
+
|
|
7
|
+
export const REVIEW_BUNDLE_PROFILES = ["outline_discussion", "editor_detailed"];
|
|
8
|
+
export const REVIEW_BUNDLE_STRICTNESS = ["warn", "fail"];
|
|
9
|
+
|
|
10
|
+
export class ReviewBundlePlanError extends Error {
|
|
11
|
+
constructor(code, message, details) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ReviewBundlePlanError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.details = details;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeSortNumber(value) {
|
|
20
|
+
return Number.isInteger(value) ? value : MAX_SORT_VALUE;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sceneSort(a, b) {
|
|
24
|
+
const partDiff = normalizeSortNumber(a.part) - normalizeSortNumber(b.part);
|
|
25
|
+
if (partDiff !== 0) return partDiff;
|
|
26
|
+
|
|
27
|
+
const chapterDiff = normalizeSortNumber(a.chapter) - normalizeSortNumber(b.chapter);
|
|
28
|
+
if (chapterDiff !== 0) return chapterDiff;
|
|
29
|
+
|
|
30
|
+
const timelineDiff = normalizeSortNumber(a.timeline_position) - normalizeSortNumber(b.timeline_position);
|
|
31
|
+
if (timelineDiff !== 0) return timelineDiff;
|
|
32
|
+
|
|
33
|
+
return String(a.scene_id).localeCompare(String(b.scene_id));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildWarningSummary(warnings) {
|
|
37
|
+
const summary = {};
|
|
38
|
+
for (const warning of warnings) {
|
|
39
|
+
const type = warning.type ?? "unknown";
|
|
40
|
+
if (!summary[type]) {
|
|
41
|
+
summary[type] = { count: 0, examples: [] };
|
|
42
|
+
}
|
|
43
|
+
summary[type].count += 1;
|
|
44
|
+
if (summary[type].examples.length < 5) {
|
|
45
|
+
summary[type].examples.push(warning.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return summary;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function slugifyBundleName(value) {
|
|
52
|
+
const slug = String(value ?? "")
|
|
53
|
+
.trim()
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
56
|
+
.replace(/^-+|-+$/g, "");
|
|
57
|
+
return slug || "review-bundle";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function escapeMarkdown(text) {
|
|
61
|
+
return String(text ?? "")
|
|
62
|
+
.replace(/\\/g, "\\\\")
|
|
63
|
+
.replace(/([*_`\[\]#])/g, "\\$1");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveOutputFilePath(outputDir, fileName) {
|
|
67
|
+
const normalizedOutputDir = path.resolve(outputDir);
|
|
68
|
+
const target = path.resolve(normalizedOutputDir, fileName);
|
|
69
|
+
const rel = path.relative(normalizedOutputDir, target);
|
|
70
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
71
|
+
throw new ReviewBundlePlanError(
|
|
72
|
+
"INVALID_OUTPUT_PATH",
|
|
73
|
+
`Output file '${fileName}' resolves outside output_dir.`,
|
|
74
|
+
{ output_dir: normalizedOutputDir, file_name: fileName }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return target;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function assertProfile(profile) {
|
|
81
|
+
if (!REVIEW_BUNDLE_PROFILES.includes(profile)) {
|
|
82
|
+
throw new ReviewBundlePlanError(
|
|
83
|
+
"INVALID_PROFILE",
|
|
84
|
+
`Unsupported review bundle profile '${profile}'.`,
|
|
85
|
+
{ supported_profiles: REVIEW_BUNDLE_PROFILES }
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertStrictness(strictness) {
|
|
91
|
+
if (!REVIEW_BUNDLE_STRICTNESS.includes(strictness)) {
|
|
92
|
+
throw new ReviewBundlePlanError(
|
|
93
|
+
"INVALID_STRICTNESS",
|
|
94
|
+
`Unsupported strictness '${strictness}'.`,
|
|
95
|
+
{ supported_strictness: REVIEW_BUNDLE_STRICTNESS }
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveRequestedSceneIds(dbHandle, projectId, sceneIds) {
|
|
101
|
+
if (!Array.isArray(sceneIds) || sceneIds.length === 0) {
|
|
102
|
+
return { requested: [], existing: new Set() };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const placeholders = sceneIds.map(() => "?").join(",");
|
|
106
|
+
const rows = dbHandle.prepare(
|
|
107
|
+
`SELECT scene_id FROM scenes WHERE project_id = ? AND scene_id IN (${placeholders})`
|
|
108
|
+
).all(projectId, ...sceneIds);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
requested: sceneIds,
|
|
112
|
+
existing: new Set(rows.map(row => row.scene_id)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildReviewBundlePlan(dbHandle, {
|
|
117
|
+
project_id,
|
|
118
|
+
profile,
|
|
119
|
+
part,
|
|
120
|
+
chapter,
|
|
121
|
+
tag,
|
|
122
|
+
scene_ids,
|
|
123
|
+
strictness = "warn",
|
|
124
|
+
include_scene_ids = true,
|
|
125
|
+
include_metadata_sidebar = false,
|
|
126
|
+
include_paragraph_anchors = false,
|
|
127
|
+
bundle_name,
|
|
128
|
+
} = {}) {
|
|
129
|
+
if (!project_id) {
|
|
130
|
+
throw new ReviewBundlePlanError("INVALID_PROJECT_ID", "project_id is required.");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
assertProfile(profile);
|
|
134
|
+
assertStrictness(strictness);
|
|
135
|
+
|
|
136
|
+
const projectRow = dbHandle.prepare(`SELECT project_id FROM projects WHERE project_id = ?`).get(project_id);
|
|
137
|
+
if (!projectRow) {
|
|
138
|
+
throw new ReviewBundlePlanError("NOT_FOUND", `Project '${project_id}' not found.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const requestedSceneIds = resolveRequestedSceneIds(dbHandle, project_id, scene_ids);
|
|
142
|
+
const conditions = ["s.project_id = ?"];
|
|
143
|
+
const params = [project_id];
|
|
144
|
+
const joins = [];
|
|
145
|
+
|
|
146
|
+
if (tag) {
|
|
147
|
+
joins.push("JOIN scene_tags st ON st.scene_id = s.scene_id AND st.tag = ?");
|
|
148
|
+
params.push(tag);
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(scene_ids) && scene_ids.length > 0) {
|
|
151
|
+
const placeholders = scene_ids.map(() => "?").join(",");
|
|
152
|
+
conditions.push(`s.scene_id IN (${placeholders})`);
|
|
153
|
+
params.push(...scene_ids);
|
|
154
|
+
}
|
|
155
|
+
if (part !== undefined) {
|
|
156
|
+
conditions.push("s.part = ?");
|
|
157
|
+
params.push(part);
|
|
158
|
+
}
|
|
159
|
+
if (chapter !== undefined) {
|
|
160
|
+
conditions.push("s.chapter = ?");
|
|
161
|
+
params.push(chapter);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let query = `
|
|
165
|
+
SELECT DISTINCT
|
|
166
|
+
s.scene_id,
|
|
167
|
+
s.project_id,
|
|
168
|
+
s.title,
|
|
169
|
+
s.part,
|
|
170
|
+
s.chapter,
|
|
171
|
+
s.timeline_position,
|
|
172
|
+
s.word_count,
|
|
173
|
+
s.logline,
|
|
174
|
+
s.pov,
|
|
175
|
+
s.save_the_cat_beat,
|
|
176
|
+
s.metadata_stale
|
|
177
|
+
FROM scenes s
|
|
178
|
+
`;
|
|
179
|
+
|
|
180
|
+
if (joins.length > 0) {
|
|
181
|
+
query += ` ${joins.join(" ")}`;
|
|
182
|
+
}
|
|
183
|
+
query += ` WHERE ${conditions.join(" AND ")}`;
|
|
184
|
+
|
|
185
|
+
const rows = dbHandle.prepare(query).all(...params).sort(sceneSort);
|
|
186
|
+
if (rows.length === 0) {
|
|
187
|
+
throw new ReviewBundlePlanError(
|
|
188
|
+
"NO_RESULTS",
|
|
189
|
+
"No scenes matched the requested review bundle scope.",
|
|
190
|
+
{
|
|
191
|
+
project_id,
|
|
192
|
+
filters: {
|
|
193
|
+
...(part !== undefined ? { part } : {}),
|
|
194
|
+
...(chapter !== undefined ? { chapter } : {}),
|
|
195
|
+
...(tag ? { tag } : {}),
|
|
196
|
+
...(Array.isArray(scene_ids) ? { scene_ids } : {}),
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const includedSceneIds = new Set(rows.map(row => row.scene_id));
|
|
203
|
+
const excludedSceneIds = requestedSceneIds.requested.filter(sceneId => !includedSceneIds.has(sceneId));
|
|
204
|
+
const notFoundSceneIds = requestedSceneIds.requested.filter(sceneId => !requestedSceneIds.existing.has(sceneId));
|
|
205
|
+
const filteredOutSceneIds = excludedSceneIds.filter(sceneId => requestedSceneIds.existing.has(sceneId));
|
|
206
|
+
|
|
207
|
+
const warnings = [];
|
|
208
|
+
|
|
209
|
+
if (notFoundSceneIds.length > 0) {
|
|
210
|
+
warnings.push({
|
|
211
|
+
type: "requested_scene_ids_not_found",
|
|
212
|
+
message: `${notFoundSceneIds.length} requested scene_id value(s) do not exist in project '${project_id}'.`,
|
|
213
|
+
scene_ids: notFoundSceneIds,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (filteredOutSceneIds.length > 0) {
|
|
218
|
+
warnings.push({
|
|
219
|
+
type: "requested_scene_ids_filtered_out",
|
|
220
|
+
message: `${filteredOutSceneIds.length} requested scene_id value(s) were excluded by additional filters.`,
|
|
221
|
+
scene_ids: filteredOutSceneIds,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const staleRows = rows.filter(row => Number(row.metadata_stale) === 1);
|
|
226
|
+
if (staleRows.length > 0) {
|
|
227
|
+
warnings.push({
|
|
228
|
+
type: "metadata_stale",
|
|
229
|
+
message: `${staleRows.length} scene(s) have stale metadata and may need re-enrichment before editorial use.`,
|
|
230
|
+
count: staleRows.length,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const missingOrderingRows = rows.filter(
|
|
235
|
+
row => row.part == null || row.chapter == null || row.timeline_position == null
|
|
236
|
+
);
|
|
237
|
+
if (missingOrderingRows.length > 0) {
|
|
238
|
+
warnings.push({
|
|
239
|
+
type: "missing_ordering_fields",
|
|
240
|
+
message: `${missingOrderingRows.length} scene(s) are missing part/chapter/timeline_position metadata; fallback ordering was applied.`,
|
|
241
|
+
count: missingOrderingRows.length,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const missingWordCountRows = rows.filter(row => row.word_count == null);
|
|
246
|
+
if (missingWordCountRows.length > 0) {
|
|
247
|
+
warnings.push({
|
|
248
|
+
type: "missing_word_count",
|
|
249
|
+
message: `${missingWordCountRows.length} scene(s) are missing word_count; estimated_word_count may be low.`,
|
|
250
|
+
count: missingWordCountRows.length,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const blockers = [];
|
|
255
|
+
if (strictness === "fail" && staleRows.length > 0) {
|
|
256
|
+
blockers.push({
|
|
257
|
+
code: "STALE_METADATA",
|
|
258
|
+
message: `${staleRows.length} scene(s) are marked metadata_stale.`,
|
|
259
|
+
scene_ids: staleRows.map(row => row.scene_id),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const estimatedWordCount = rows.reduce((sum, row) => {
|
|
264
|
+
const count = Number(row.word_count);
|
|
265
|
+
return sum + (Number.isFinite(count) ? count : 0);
|
|
266
|
+
}, 0);
|
|
267
|
+
|
|
268
|
+
const safeBundleName = slugifyBundleName(bundle_name || `${project_id}-${profile}`);
|
|
269
|
+
const appliedFilters = {
|
|
270
|
+
...(part !== undefined ? { part } : {}),
|
|
271
|
+
...(chapter !== undefined ? { chapter } : {}),
|
|
272
|
+
...(tag ? { tag } : {}),
|
|
273
|
+
...(Array.isArray(scene_ids) ? { scene_ids } : {}),
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
profile,
|
|
279
|
+
resolved_scope: {
|
|
280
|
+
project_id,
|
|
281
|
+
filters: appliedFilters,
|
|
282
|
+
options: {
|
|
283
|
+
include_scene_ids: Boolean(include_scene_ids),
|
|
284
|
+
include_metadata_sidebar: Boolean(include_metadata_sidebar),
|
|
285
|
+
include_paragraph_anchors: Boolean(include_paragraph_anchors),
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
ordering: rows.map(row => ({
|
|
289
|
+
scene_id: row.scene_id,
|
|
290
|
+
project_id: row.project_id,
|
|
291
|
+
title: row.title,
|
|
292
|
+
part: row.part,
|
|
293
|
+
chapter: row.chapter,
|
|
294
|
+
timeline_position: row.timeline_position,
|
|
295
|
+
metadata_stale: Number(row.metadata_stale) === 1,
|
|
296
|
+
})),
|
|
297
|
+
summary: {
|
|
298
|
+
scene_count: rows.length,
|
|
299
|
+
estimated_word_count: estimatedWordCount,
|
|
300
|
+
excluded_scene_ids: excludedSceneIds,
|
|
301
|
+
},
|
|
302
|
+
warnings,
|
|
303
|
+
warning_summary: buildWarningSummary(warnings),
|
|
304
|
+
strictness_result: {
|
|
305
|
+
strictness,
|
|
306
|
+
can_proceed: blockers.length === 0,
|
|
307
|
+
blockers,
|
|
308
|
+
},
|
|
309
|
+
planned_outputs: [
|
|
310
|
+
`${safeBundleName}.md`,
|
|
311
|
+
`${safeBundleName}.manifest.json`,
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function loadBundleSceneRows(dbHandle, projectId, sceneIds) {
|
|
317
|
+
if (!Array.isArray(sceneIds) || sceneIds.length === 0) return [];
|
|
318
|
+
const rows = [];
|
|
319
|
+
// 900 is safely below SQLite's per-query bound of 999 host parameters
|
|
320
|
+
// (one slot is used by the project_id binding, leaving 998 for scene_id placeholders;
|
|
321
|
+
// 900 gives extra headroom for any future additions to the query).
|
|
322
|
+
const chunkSize = 900;
|
|
323
|
+
for (let offset = 0; offset < sceneIds.length; offset += chunkSize) {
|
|
324
|
+
const chunk = sceneIds.slice(offset, offset + chunkSize);
|
|
325
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
326
|
+
const chunkRows = dbHandle.prepare(`
|
|
327
|
+
SELECT
|
|
328
|
+
scene_id,
|
|
329
|
+
project_id,
|
|
330
|
+
title,
|
|
331
|
+
part,
|
|
332
|
+
chapter,
|
|
333
|
+
timeline_position,
|
|
334
|
+
logline,
|
|
335
|
+
pov,
|
|
336
|
+
save_the_cat_beat,
|
|
337
|
+
file_path
|
|
338
|
+
FROM scenes
|
|
339
|
+
WHERE project_id = ? AND scene_id IN (${placeholders})
|
|
340
|
+
`).all(projectId, ...chunk);
|
|
341
|
+
rows.push(...chunkRows);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const rowMap = new Map(rows.map(row => [row.scene_id, row]));
|
|
345
|
+
const orderedRows = [];
|
|
346
|
+
const missingSceneIds = [];
|
|
347
|
+
|
|
348
|
+
for (const sceneId of sceneIds) {
|
|
349
|
+
const row = rowMap.get(sceneId);
|
|
350
|
+
if (row) {
|
|
351
|
+
orderedRows.push(row);
|
|
352
|
+
} else {
|
|
353
|
+
missingSceneIds.push(sceneId);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (missingSceneIds.length > 0) {
|
|
358
|
+
throw new ReviewBundlePlanError(
|
|
359
|
+
"MISSING_SCENE_ROWS",
|
|
360
|
+
`Bundle includes ${missingSceneIds.length} scene(s) that could not be loaded from the database.`,
|
|
361
|
+
{
|
|
362
|
+
project_id: projectId,
|
|
363
|
+
missing_scene_ids: missingSceneIds,
|
|
364
|
+
requested_scene_count: sceneIds.length,
|
|
365
|
+
resolved_scene_count: orderedRows.length,
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return orderedRows;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function normalizeRelativePath(inputPath) {
|
|
374
|
+
return String(inputPath).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function resolveSceneFilePath(filePath, { syncDir } = {}) {
|
|
378
|
+
if (!filePath || !syncDir) return null;
|
|
379
|
+
|
|
380
|
+
const normalizedSyncDir = path.resolve(syncDir);
|
|
381
|
+
let realSyncDir;
|
|
382
|
+
try {
|
|
383
|
+
realSyncDir = fs.realpathSync.native(normalizedSyncDir);
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const rel = normalizeRelativePath(filePath);
|
|
389
|
+
const candidates = [];
|
|
390
|
+
|
|
391
|
+
if (path.isAbsolute(filePath)) {
|
|
392
|
+
// Canonicalize the absolute path (resolve symlinks) so the boundary check
|
|
393
|
+
// works correctly even when syncDir itself contains a symlink component
|
|
394
|
+
// (e.g. macOS /var → /private/var or /tmp → /private/tmp).
|
|
395
|
+
const resolvedAbsolute = path.resolve(filePath);
|
|
396
|
+
let canonicalAbsolute;
|
|
397
|
+
|
|
398
|
+
if (fs.existsSync(resolvedAbsolute)) {
|
|
399
|
+
try {
|
|
400
|
+
canonicalAbsolute = fs.realpathSync.native(resolvedAbsolute);
|
|
401
|
+
} catch {
|
|
402
|
+
// Cannot canonicalize — skip this candidate.
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
// File doesn't exist yet; walk up to the nearest existing ancestor,
|
|
406
|
+
// canonicalize that, then reconstruct the full path.
|
|
407
|
+
let ancestor = resolvedAbsolute;
|
|
408
|
+
const segments = [];
|
|
409
|
+
while (!fs.existsSync(ancestor)) {
|
|
410
|
+
const parent = path.dirname(ancestor);
|
|
411
|
+
if (parent === ancestor) { ancestor = null; break; }
|
|
412
|
+
segments.unshift(path.basename(ancestor));
|
|
413
|
+
ancestor = parent;
|
|
414
|
+
}
|
|
415
|
+
if (ancestor) {
|
|
416
|
+
try {
|
|
417
|
+
const realAncestor = fs.realpathSync.native(ancestor);
|
|
418
|
+
canonicalAbsolute = path.resolve(realAncestor, ...segments);
|
|
419
|
+
} catch {
|
|
420
|
+
// Cannot canonicalize.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (canonicalAbsolute) {
|
|
426
|
+
const relFromSync = path.relative(realSyncDir, canonicalAbsolute);
|
|
427
|
+
if (!relFromSync.startsWith("..") && !path.isAbsolute(relFromSync)) {
|
|
428
|
+
candidates.push(canonicalAbsolute);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
candidates.push(path.resolve(realSyncDir, rel));
|
|
433
|
+
// Scrivener External Folder Sync sometimes stores paths prefixed with
|
|
434
|
+
// "sync/" (the name of the sync folder itself) relative to the project
|
|
435
|
+
// root. Strip that prefix so we can find the file within realSyncDir.
|
|
436
|
+
if (rel === "sync" || rel.startsWith("sync/")) {
|
|
437
|
+
candidates.push(path.resolve(realSyncDir, rel.replace(/^sync\/?/, "")));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
for (const candidate of candidates) {
|
|
442
|
+
if (!fs.existsSync(candidate)) {
|
|
443
|
+
// Before returning a non-existent path, verify it is still inside realSyncDir.
|
|
444
|
+
// A relative filePath with .. segments could otherwise escape the boundary.
|
|
445
|
+
const relFromSync = path.relative(realSyncDir, candidate);
|
|
446
|
+
if (!relFromSync.startsWith("..") && !path.isAbsolute(relFromSync)) {
|
|
447
|
+
return candidate;
|
|
448
|
+
}
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// File exists: validate realpath stays inside syncDir to catch symlink escapes.
|
|
453
|
+
// (For absolute paths this is already canonicalized; for relative paths, verify.)
|
|
454
|
+
try {
|
|
455
|
+
const realCandidate = fs.realpathSync.native(candidate);
|
|
456
|
+
const relReal = path.relative(realSyncDir, realCandidate);
|
|
457
|
+
if (!relReal.startsWith("..") && !path.isAbsolute(relReal)) {
|
|
458
|
+
return realCandidate;
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function readProse(filePath, { syncDir } = {}) {
|
|
469
|
+
const resolvedPath = resolveSceneFilePath(filePath, { syncDir });
|
|
470
|
+
if (!resolvedPath) return null;
|
|
471
|
+
try {
|
|
472
|
+
const raw = fs.readFileSync(resolvedPath, "utf8");
|
|
473
|
+
return matter(raw).content.trim();
|
|
474
|
+
} catch (error) {
|
|
475
|
+
const errorCode = error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
|
476
|
+
? error.code
|
|
477
|
+
: null;
|
|
478
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
479
|
+
throw new ReviewBundlePlanError(
|
|
480
|
+
"SCENE_PROSE_READ_FAILED",
|
|
481
|
+
`Failed to read scene prose from ${resolvedPath}.`,
|
|
482
|
+
{
|
|
483
|
+
file_path: filePath,
|
|
484
|
+
resolved_path: resolvedPath,
|
|
485
|
+
error_code: errorCode,
|
|
486
|
+
cause: errorMessage,
|
|
487
|
+
}
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function renderSceneBlock(scene, options) {
|
|
493
|
+
const {
|
|
494
|
+
profile,
|
|
495
|
+
includeSceneIds,
|
|
496
|
+
includeMetadataSidebar,
|
|
497
|
+
includeParagraphAnchors,
|
|
498
|
+
} = options;
|
|
499
|
+
|
|
500
|
+
const title = scene.title || scene.scene_id;
|
|
501
|
+
const sceneHeading = includeSceneIds
|
|
502
|
+
? `## ${escapeMarkdown(title)} (${escapeMarkdown(scene.scene_id)})`
|
|
503
|
+
: `## ${escapeMarkdown(title)}`;
|
|
504
|
+
|
|
505
|
+
const parts = [sceneHeading];
|
|
506
|
+
|
|
507
|
+
if (profile === "outline_discussion") {
|
|
508
|
+
const summaryParts = [];
|
|
509
|
+
if (scene.pov) summaryParts.push(`POV: ${scene.pov}`);
|
|
510
|
+
if (scene.save_the_cat_beat) summaryParts.push(`Beat: ${scene.save_the_cat_beat}`);
|
|
511
|
+
if (scene.part != null) summaryParts.push(`Part: ${scene.part}`);
|
|
512
|
+
if (scene.chapter != null) summaryParts.push(`Chapter: ${scene.chapter}`);
|
|
513
|
+
if (summaryParts.length > 0) {
|
|
514
|
+
parts.push(`_${escapeMarkdown(summaryParts.join(" | "))}_`);
|
|
515
|
+
}
|
|
516
|
+
if (scene.logline) {
|
|
517
|
+
parts.push(escapeMarkdown(scene.logline.trim()));
|
|
518
|
+
}
|
|
519
|
+
return parts.join("\n\n");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (includeMetadataSidebar) {
|
|
523
|
+
const sidebar = [
|
|
524
|
+
scene.part != null ? `part: ${scene.part}` : null,
|
|
525
|
+
scene.chapter != null ? `chapter: ${scene.chapter}` : null,
|
|
526
|
+
scene.timeline_position != null ? `timeline_position: ${scene.timeline_position}` : null,
|
|
527
|
+
scene.pov ? `pov: ${escapeMarkdown(scene.pov)}` : null,
|
|
528
|
+
scene.save_the_cat_beat ? `beat: ${escapeMarkdown(scene.save_the_cat_beat)}` : null,
|
|
529
|
+
].filter(Boolean);
|
|
530
|
+
if (sidebar.length > 0) {
|
|
531
|
+
parts.push(`> ${sidebar.join(" \\\n> ")}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const prose = scene.prose ?? "";
|
|
536
|
+
if (!includeParagraphAnchors || prose.length === 0) {
|
|
537
|
+
parts.push(prose);
|
|
538
|
+
return parts.join("\n\n");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const paragraphs = prose
|
|
542
|
+
.split(/\n\s*\n/g)
|
|
543
|
+
.map(p => p.trim())
|
|
544
|
+
.filter(Boolean);
|
|
545
|
+
// Sanitize scene_id for safe embedding in an HTML comment: restrict to
|
|
546
|
+
// alphanumerics, hyphens, underscores, and dots to prevent "-->" or other
|
|
547
|
+
// sequences from prematurely terminating the comment.
|
|
548
|
+
const safeSceneId = scene.scene_id.replace(/[^a-zA-Z0-9\-_.]/g, "_");
|
|
549
|
+
const anchoredParagraphs = paragraphs.map((paragraph, index) => {
|
|
550
|
+
return `<!-- ${safeSceneId}:p${index + 1} -->\n${paragraph}`;
|
|
551
|
+
});
|
|
552
|
+
parts.push(anchoredParagraphs.join("\n\n"));
|
|
553
|
+
return parts.join("\n\n");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
|
|
557
|
+
const profile = plan.profile;
|
|
558
|
+
const includeSceneIds = Boolean(plan.resolved_scope?.options?.include_scene_ids);
|
|
559
|
+
const includeMetadataSidebar = Boolean(plan.resolved_scope?.options?.include_metadata_sidebar);
|
|
560
|
+
const includeParagraphAnchors = Boolean(plan.resolved_scope?.options?.include_paragraph_anchors);
|
|
561
|
+
// Prefer explicitly threaded syncDir; fall back to env (with "./sync" default matching index.js).
|
|
562
|
+
// Prefer explicitly threaded syncDir; fall back to env.
|
|
563
|
+
// No further fallback: if syncDir is null, resolveSceneFilePath returns null
|
|
564
|
+
// and SCENE_PROSE_READ_FAILED is thrown, making misconfiguration explicit.
|
|
565
|
+
const syncDir = syncDirOpt ?? process.env.WRITING_SYNC_DIR ?? null;
|
|
566
|
+
|
|
567
|
+
const sceneIds = plan.ordering.map(row => row.scene_id);
|
|
568
|
+
const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
|
|
569
|
+
const sections = [];
|
|
570
|
+
|
|
571
|
+
const headerLines = [
|
|
572
|
+
`# Review Bundle: ${escapeMarkdown(plan.resolved_scope.project_id)}`,
|
|
573
|
+
"",
|
|
574
|
+
`- Profile: ${profile}`,
|
|
575
|
+
`- Generated at: ${generatedAt ?? new Date().toISOString()}`,
|
|
576
|
+
`- Scene count: ${plan.summary.scene_count}`,
|
|
577
|
+
];
|
|
578
|
+
sections.push(headerLines.join("\n"));
|
|
579
|
+
|
|
580
|
+
for (const scene of rows) {
|
|
581
|
+
let prose = "";
|
|
582
|
+
if (profile === "editor_detailed") {
|
|
583
|
+
const resolved = readProse(scene.file_path, { syncDir });
|
|
584
|
+
if (resolved === null) {
|
|
585
|
+
throw new ReviewBundlePlanError(
|
|
586
|
+
"SCENE_PROSE_READ_FAILED",
|
|
587
|
+
`Scene prose is unavailable for scene ${scene.scene_id}: file_path is null or could not be resolved within syncDir.`,
|
|
588
|
+
{
|
|
589
|
+
scene_id: scene.scene_id,
|
|
590
|
+
file_path: scene.file_path ?? null,
|
|
591
|
+
sync_dir: syncDir,
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
prose = resolved;
|
|
596
|
+
}
|
|
597
|
+
const withProse = { ...scene, prose };
|
|
598
|
+
sections.push(renderSceneBlock(withProse, {
|
|
599
|
+
profile,
|
|
600
|
+
includeSceneIds,
|
|
601
|
+
includeMetadataSidebar,
|
|
602
|
+
includeParagraphAnchors,
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return sections.join("\n\n---\n\n").trim() + "\n";
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function createReviewBundleArtifacts(dbHandle, {
|
|
610
|
+
plan,
|
|
611
|
+
output_dir,
|
|
612
|
+
source_commit = null,
|
|
613
|
+
syncDir,
|
|
614
|
+
}) {
|
|
615
|
+
if (!output_dir) {
|
|
616
|
+
throw new ReviewBundlePlanError("INVALID_OUTPUT_DIR", "output_dir is required.");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const normalizedOutputDir = path.resolve(output_dir);
|
|
620
|
+
if (fs.existsSync(normalizedOutputDir)) {
|
|
621
|
+
if (!fs.statSync(normalizedOutputDir).isDirectory()) {
|
|
622
|
+
throw new ReviewBundlePlanError(
|
|
623
|
+
"INVALID_OUTPUT_DIR",
|
|
624
|
+
`output_dir exists but is not a directory: ${normalizedOutputDir}`
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
} else {
|
|
628
|
+
fs.mkdirSync(normalizedOutputDir, { recursive: true });
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
fs.accessSync(normalizedOutputDir, fs.constants.W_OK);
|
|
632
|
+
} catch {
|
|
633
|
+
throw new ReviewBundlePlanError(
|
|
634
|
+
"INVALID_OUTPUT_DIR",
|
|
635
|
+
`output_dir is not writable: ${normalizedOutputDir}`
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const markdownFileName = plan.planned_outputs.find(name => name.endsWith(".md"));
|
|
640
|
+
const manifestFileName = plan.planned_outputs.find(name => name.endsWith(".manifest.json"));
|
|
641
|
+
if (!markdownFileName || !manifestFileName) {
|
|
642
|
+
throw new ReviewBundlePlanError(
|
|
643
|
+
"INVALID_PLAN_OUTPUTS",
|
|
644
|
+
"Plan is missing expected markdown/manifest filenames."
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const markdownPath = resolveOutputFilePath(normalizedOutputDir, markdownFileName);
|
|
649
|
+
const manifestPath = resolveOutputFilePath(normalizedOutputDir, manifestFileName);
|
|
650
|
+
|
|
651
|
+
const generatedAt = new Date().toISOString();
|
|
652
|
+
const markdown = renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir });
|
|
653
|
+
const manifest = {
|
|
654
|
+
bundle_id: path.basename(markdownFileName, ".md"),
|
|
655
|
+
profile: plan.profile,
|
|
656
|
+
generated_at: generatedAt,
|
|
657
|
+
provenance: {
|
|
658
|
+
source_commit: source_commit ?? null,
|
|
659
|
+
project_id: plan.resolved_scope.project_id,
|
|
660
|
+
},
|
|
661
|
+
summary: plan.summary,
|
|
662
|
+
warning_summary: plan.warning_summary,
|
|
663
|
+
warnings: plan.warnings,
|
|
664
|
+
resolved_scope: plan.resolved_scope,
|
|
665
|
+
scene_ids: plan.ordering.map(row => row.scene_id),
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
for (const outputPath of [markdownPath, manifestPath]) {
|
|
669
|
+
try {
|
|
670
|
+
const stat = fs.lstatSync(outputPath);
|
|
671
|
+
if (stat.isSymbolicLink()) {
|
|
672
|
+
throw new ReviewBundlePlanError(
|
|
673
|
+
"INVALID_OUTPUT_PATH",
|
|
674
|
+
`Refusing to write: target path is a symlink: ${outputPath}`
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
if (!stat.isFile()) {
|
|
678
|
+
throw new ReviewBundlePlanError(
|
|
679
|
+
"INVALID_OUTPUT_PATH",
|
|
680
|
+
`Refusing to write: target path exists but is not a regular file: ${outputPath}`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (error instanceof ReviewBundlePlanError) throw error;
|
|
685
|
+
if (error?.code !== "ENOENT") throw error;
|
|
686
|
+
// ENOENT — file doesn't exist yet, which is the expected case.
|
|
687
|
+
// Note: there is an inherent TOCTOU window between this lstat check and the
|
|
688
|
+
// writeFileSync below. This is acceptable for a local tool where the caller
|
|
689
|
+
// controls the output directory.
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
fs.writeFileSync(markdownPath, markdown, "utf8");
|
|
694
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
bundle_id: manifest.bundle_id,
|
|
698
|
+
output_paths: {
|
|
699
|
+
bundle_markdown: markdownPath,
|
|
700
|
+
manifest_json: manifestPath,
|
|
701
|
+
},
|
|
702
|
+
generated_at: generatedAt,
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import process from "process";
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
const [projectId, profile, outputDir, ...flags] = process.argv.slice(2);
|
|
8
|
+
if (!projectId || !profile || !outputDir) {
|
|
9
|
+
console.error("Usage: WRITING_SYNC_DIR=/path/to/sync node scripts/manual/run_create_review_bundle.js <project_id> <profile> <output_dir> [--anchors] [--bundle-name <name>]");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!process.env.WRITING_SYNC_DIR) {
|
|
14
|
+
console.error(
|
|
15
|
+
"WRITING_SYNC_DIR is required. Set it to the root of your sync directory.\n" +
|
|
16
|
+
"Usage: WRITING_SYNC_DIR=/path/to/sync node scripts/manual/run_create_review_bundle.js <project_id> <profile> <output_dir>"
|
|
17
|
+
);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const includeParagraphAnchors = flags.includes("--anchors");
|
|
22
|
+
const bundleNameIdx = flags.indexOf("--bundle-name");
|
|
23
|
+
const bundleName = bundleNameIdx !== -1 ? flags[bundleNameIdx + 1] : undefined;
|
|
24
|
+
|
|
25
|
+
const transport = new StdioClientTransport({
|
|
26
|
+
command: process.execPath,
|
|
27
|
+
args: ["--experimental-sqlite", path.join(process.cwd(), "index.js")],
|
|
28
|
+
env: {
|
|
29
|
+
...process.env,
|
|
30
|
+
MCP_TRANSPORT: "stdio",
|
|
31
|
+
WRITING_SYNC_DIR: process.env.WRITING_SYNC_DIR
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const client = new Client({
|
|
36
|
+
name: "test-client",
|
|
37
|
+
version: "1.0.0"
|
|
38
|
+
}, {
|
|
39
|
+
capabilities: {}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await client.connect(transport);
|
|
44
|
+
const result = await client.callTool({
|
|
45
|
+
name: "create_review_bundle",
|
|
46
|
+
arguments: {
|
|
47
|
+
project_id: projectId,
|
|
48
|
+
profile: profile,
|
|
49
|
+
output_dir: outputDir,
|
|
50
|
+
...(includeParagraphAnchors ? { include_paragraph_anchors: true } : {}),
|
|
51
|
+
...(bundleName ? { bundle_name: bundleName } : {})
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
console.log(JSON.stringify(result, null, 2));
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error(e);
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
} finally {
|
|
60
|
+
process.exit(process.exitCode ?? 0);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
main();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
|
+
|
|
6
|
+
async function callCreateBundle(client, args) {
|
|
7
|
+
const result = await client.callTool({
|
|
8
|
+
name: "create_review_bundle",
|
|
9
|
+
arguments: args,
|
|
10
|
+
});
|
|
11
|
+
console.log(JSON.stringify(result, null, 2));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
const [projectId, outputDirArg] = process.argv.slice(2);
|
|
16
|
+
const envWritingSyncDir = process.env.WRITING_SYNC_DIR;
|
|
17
|
+
|
|
18
|
+
if (!projectId) {
|
|
19
|
+
console.error(
|
|
20
|
+
"Usage: WRITING_SYNC_DIR=/path/to/sync node scripts/manual/run_mcp_and_review.js <projectId> [outputDir]"
|
|
21
|
+
);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!envWritingSyncDir) {
|
|
26
|
+
console.error(
|
|
27
|
+
"WRITING_SYNC_DIR is required. Set it to the root of your sync directory.\n" +
|
|
28
|
+
"Usage: WRITING_SYNC_DIR=/path/to/sync node scripts/manual/run_mcp_and_review.js <projectId> [outputDir]"
|
|
29
|
+
);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const outputDir = outputDirArg || path.join(envWritingSyncDir, "exports");
|
|
34
|
+
const writingSyncDir = envWritingSyncDir;
|
|
35
|
+
|
|
36
|
+
const transport = new StdioClientTransport({
|
|
37
|
+
command: process.execPath,
|
|
38
|
+
args: ["--experimental-sqlite", path.join(process.cwd(), "index.js")],
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
MCP_TRANSPORT: "stdio",
|
|
42
|
+
WRITING_SYNC_DIR: writingSyncDir,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const client = new Client(
|
|
47
|
+
{ name: "manual-review-runner", version: "1.0.0" },
|
|
48
|
+
{ capabilities: {} }
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await client.connect(transport);
|
|
53
|
+
|
|
54
|
+
console.log("Calling create_review_bundle (outline_discussion)...");
|
|
55
|
+
await callCreateBundle(client, {
|
|
56
|
+
project_id: projectId,
|
|
57
|
+
profile: "outline_discussion",
|
|
58
|
+
output_dir: outputDir,
|
|
59
|
+
bundle_name: "real-outline",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
console.log("Calling create_review_bundle (editor_detailed)...");
|
|
63
|
+
await callCreateBundle(client, {
|
|
64
|
+
project_id: projectId,
|
|
65
|
+
profile: "editor_detailed",
|
|
66
|
+
include_paragraph_anchors: true,
|
|
67
|
+
output_dir: outputDir,
|
|
68
|
+
bundle_name: "real-editor",
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error(error);
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
} finally {
|
|
74
|
+
process.exit(process.exitCode ?? 0);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
main();
|