@hanna84/mcp-writing 2.10.0 → 2.10.2

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/review-bundles.js CHANGED
@@ -1,998 +1,14 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import matter from "gray-matter";
4
- import PDFDocument from "pdfkit";
5
-
6
- const MAX_SORT_VALUE = Number.MAX_SAFE_INTEGER;
7
-
8
- export const REVIEW_BUNDLE_PROFILES = ["outline_discussion", "editor_detailed", "beta_reader_personalized"];
9
- export const REVIEW_BUNDLE_STRICTNESS = ["warn", "fail"];
10
-
11
- export class ReviewBundlePlanError extends Error {
12
- constructor(code, message, details) {
13
- super(message);
14
- this.name = "ReviewBundlePlanError";
15
- this.code = code;
16
- this.details = details;
17
- }
18
- }
19
-
20
- function normalizeSortNumber(value) {
21
- return Number.isInteger(value) ? value : MAX_SORT_VALUE;
22
- }
23
-
24
- function sceneSort(a, b) {
25
- const partDiff = normalizeSortNumber(a.part) - normalizeSortNumber(b.part);
26
- if (partDiff !== 0) return partDiff;
27
-
28
- const chapterDiff = normalizeSortNumber(a.chapter) - normalizeSortNumber(b.chapter);
29
- if (chapterDiff !== 0) return chapterDiff;
30
-
31
- const timelineDiff = normalizeSortNumber(a.timeline_position) - normalizeSortNumber(b.timeline_position);
32
- if (timelineDiff !== 0) return timelineDiff;
33
-
34
- return String(a.scene_id).localeCompare(String(b.scene_id));
35
- }
36
-
37
- function buildWarningSummary(warnings) {
38
- const summary = {};
39
- for (const warning of warnings) {
40
- const type = warning.type ?? "unknown";
41
- if (!summary[type]) {
42
- summary[type] = { count: 0, examples: [] };
43
- }
44
- summary[type].count += 1;
45
- if (summary[type].examples.length < 5) {
46
- summary[type].examples.push(warning.message);
47
- }
48
- }
49
- return summary;
50
- }
51
-
52
- function slugifyBundleName(value) {
53
- const slug = String(value ?? "")
54
- .trim()
55
- .toLowerCase()
56
- .replace(/[^a-z0-9]+/g, "-")
57
- .replace(/^-+|-+$/g, "");
58
- return slug || "review-bundle";
59
- }
60
-
61
- function escapeMarkdown(text) {
62
- return String(text ?? "")
63
- .replace(/\\/g, "\\\\")
64
- .replace(/([*_`\[\]#])/g, "\\$1");
65
- }
66
-
67
- function normalizeRecipientDisplayName(recipientName) {
68
- const normalized = String(recipientName ?? "")
69
- .replace(/[\x00-\x1f\x7f]+/g, " ")
70
- .replace(/\s+/g, " ")
71
- .trim()
72
- .slice(0, 100);
73
-
74
- return normalized || "Beta Reader";
75
- }
76
-
77
- function renderBetaNoticeMarkdown({ projectId, recipientName }) {
78
- const displayName = normalizeRecipientDisplayName(recipientName);
79
- return [
80
- "# Non-Distribution Notice",
81
- "",
82
- `This review packet is prepared for ${escapeMarkdown(displayName)} for private beta-reading purposes only.`,
83
- "",
84
- "Please do not distribute, repost, or share this material without explicit author permission.",
85
- "",
86
- "This notice is informational only and is not legal advice.",
87
- "",
88
- `Project: ${escapeMarkdown(projectId)}`,
89
- ].join("\n") + "\n";
90
- }
91
-
92
- function renderBetaFeedbackFormMarkdown({ projectId, recipientName, generatedAt }) {
93
- const displayName = normalizeRecipientDisplayName(recipientName);
94
- const feedbackDate = String(generatedAt ?? new Date().toISOString()).slice(0, 10);
95
- return [
96
- "# Beta Reader Feedback Form",
97
- "",
98
- `- Project: ${escapeMarkdown(projectId)}`,
99
- `- Reader: ${escapeMarkdown(displayName)}`,
100
- `- Date: ${feedbackDate}`,
101
- "",
102
- "## Big-Picture Questions",
103
- "",
104
- "1. Which sections felt most compelling, and why?",
105
- "2. Where did pacing feel slow, rushed, or unclear?",
106
- "3. Were any character motivations confusing or unconvincing?",
107
- "",
108
- "## Scene-Level Notes",
109
- "",
110
- "Use scene IDs when possible.",
111
- "",
112
- "- Scene ID:",
113
- "- Comment:",
114
- "- Severity (nit / moderate / major):",
115
- "",
116
- "## Final Thoughts",
117
- "",
118
- "- What should be prioritized in the next revision?",
119
- "- Any continuity concerns to flag?",
120
- ].join("\n") + "\n";
121
- }
122
-
123
- function resolveOutputFilePath(outputDir, fileName) {
124
- const normalizedOutputDir = path.resolve(outputDir);
125
- const target = path.resolve(normalizedOutputDir, fileName);
126
- const rel = path.relative(normalizedOutputDir, target);
127
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
128
- throw new ReviewBundlePlanError(
129
- "INVALID_OUTPUT_PATH",
130
- `Output file '${fileName}' resolves outside output_dir.`,
131
- { output_dir: normalizedOutputDir, file_name: fileName }
132
- );
133
- }
134
- return target;
135
- }
136
-
137
- function assertProfile(profile) {
138
- if (!REVIEW_BUNDLE_PROFILES.includes(profile)) {
139
- throw new ReviewBundlePlanError(
140
- "INVALID_PROFILE",
141
- `Unsupported review bundle profile '${profile}'.`,
142
- { supported_profiles: REVIEW_BUNDLE_PROFILES }
143
- );
144
- }
145
- }
146
-
147
- function assertStrictness(strictness) {
148
- if (!REVIEW_BUNDLE_STRICTNESS.includes(strictness)) {
149
- throw new ReviewBundlePlanError(
150
- "INVALID_STRICTNESS",
151
- `Unsupported strictness '${strictness}'.`,
152
- { supported_strictness: REVIEW_BUNDLE_STRICTNESS }
153
- );
154
- }
155
- }
156
-
157
- export const REVIEW_BUNDLE_FORMATS = ["pdf", "markdown", "both"];
158
-
159
- function assertFormat(format) {
160
- if (!REVIEW_BUNDLE_FORMATS.includes(format)) {
161
- throw new ReviewBundlePlanError(
162
- "INVALID_FORMAT",
163
- `Unsupported format '${format}'.`,
164
- { supported_formats: REVIEW_BUNDLE_FORMATS }
165
- );
166
- }
167
- }
168
-
169
- function resolveRequestedSceneIds(dbHandle, projectId, sceneIds) {
170
- if (!Array.isArray(sceneIds) || sceneIds.length === 0) {
171
- return { requested: [], existing: new Set() };
172
- }
173
-
174
- const placeholders = sceneIds.map(() => "?").join(",");
175
- const rows = dbHandle.prepare(
176
- `SELECT scene_id FROM scenes WHERE project_id = ? AND scene_id IN (${placeholders})`
177
- ).all(projectId, ...sceneIds);
178
-
179
- return {
180
- requested: sceneIds,
181
- existing: new Set(rows.map(row => row.scene_id)),
182
- };
183
- }
184
-
185
- export function buildReviewBundlePlan(dbHandle, {
186
- project_id,
187
- profile,
188
- part,
189
- chapter,
190
- tag,
191
- scene_ids,
192
- strictness = "warn",
193
- include_scene_ids = true,
194
- include_metadata_sidebar = false,
195
- include_paragraph_anchors = false,
196
- bundle_name,
197
- recipient_name,
198
- format = "pdf",
199
- } = {}) {
200
- if (!project_id) {
201
- throw new ReviewBundlePlanError("INVALID_PROJECT_ID", "project_id is required.");
202
- }
203
-
204
- assertProfile(profile);
205
- assertStrictness(strictness);
206
- assertFormat(format);
207
-
208
- const projectRow = dbHandle.prepare(`SELECT project_id FROM projects WHERE project_id = ?`).get(project_id);
209
- if (!projectRow) {
210
- throw new ReviewBundlePlanError("NOT_FOUND", `Project '${project_id}' not found.`);
211
- }
212
-
213
- const requestedSceneIds = resolveRequestedSceneIds(dbHandle, project_id, scene_ids);
214
- const conditions = ["s.project_id = ?"];
215
- const params = [project_id];
216
- const joins = [];
217
-
218
- if (tag) {
219
- joins.push("JOIN scene_tags st ON st.scene_id = s.scene_id AND st.tag = ?");
220
- params.push(tag);
221
- }
222
- if (Array.isArray(scene_ids) && scene_ids.length > 0) {
223
- const placeholders = scene_ids.map(() => "?").join(",");
224
- conditions.push(`s.scene_id IN (${placeholders})`);
225
- params.push(...scene_ids);
226
- }
227
- if (part !== undefined) {
228
- conditions.push("s.part = ?");
229
- params.push(part);
230
- }
231
- if (chapter !== undefined) {
232
- conditions.push("s.chapter = ?");
233
- params.push(chapter);
234
- }
235
-
236
- let query = `
237
- SELECT DISTINCT
238
- s.scene_id,
239
- s.project_id,
240
- s.title,
241
- s.part,
242
- s.chapter,
243
- s.timeline_position,
244
- s.word_count,
245
- s.logline,
246
- s.pov,
247
- s.save_the_cat_beat,
248
- s.metadata_stale
249
- FROM scenes s
250
- `;
251
-
252
- if (joins.length > 0) {
253
- query += ` ${joins.join(" ")}`;
254
- }
255
- query += ` WHERE ${conditions.join(" AND ")}`;
256
-
257
- const rows = dbHandle.prepare(query).all(...params).sort(sceneSort);
258
- if (rows.length === 0) {
259
- throw new ReviewBundlePlanError(
260
- "NO_RESULTS",
261
- "No scenes matched the requested review bundle scope.",
262
- {
263
- project_id,
264
- filters: {
265
- ...(part !== undefined ? { part } : {}),
266
- ...(chapter !== undefined ? { chapter } : {}),
267
- ...(tag ? { tag } : {}),
268
- ...(Array.isArray(scene_ids) ? { scene_ids } : {}),
269
- },
270
- }
271
- );
272
- }
273
-
274
- const includedSceneIds = new Set(rows.map(row => row.scene_id));
275
- const excludedSceneIds = requestedSceneIds.requested.filter(sceneId => !includedSceneIds.has(sceneId));
276
- const notFoundSceneIds = requestedSceneIds.requested.filter(sceneId => !requestedSceneIds.existing.has(sceneId));
277
- const filteredOutSceneIds = excludedSceneIds.filter(sceneId => requestedSceneIds.existing.has(sceneId));
278
-
279
- const warnings = [];
280
-
281
- if (notFoundSceneIds.length > 0) {
282
- warnings.push({
283
- type: "requested_scene_ids_not_found",
284
- message: `${notFoundSceneIds.length} requested scene_id value(s) do not exist in project '${project_id}'.`,
285
- scene_ids: notFoundSceneIds,
286
- });
287
- }
288
-
289
- if (filteredOutSceneIds.length > 0) {
290
- warnings.push({
291
- type: "requested_scene_ids_filtered_out",
292
- message: `${filteredOutSceneIds.length} requested scene_id value(s) were excluded by additional filters.`,
293
- scene_ids: filteredOutSceneIds,
294
- });
295
- }
296
-
297
- const staleRows = rows.filter(row => Number(row.metadata_stale) === 1);
298
- if (staleRows.length > 0) {
299
- warnings.push({
300
- type: "metadata_stale",
301
- message: `${staleRows.length} scene(s) have stale metadata and may need re-enrichment before editorial use.`,
302
- count: staleRows.length,
303
- });
304
- }
305
-
306
- const missingOrderingRows = rows.filter(
307
- row => row.part == null || row.chapter == null || row.timeline_position == null
308
- );
309
- if (missingOrderingRows.length > 0) {
310
- warnings.push({
311
- type: "missing_ordering_fields",
312
- message: `${missingOrderingRows.length} scene(s) are missing part/chapter/timeline_position metadata; fallback ordering was applied.`,
313
- count: missingOrderingRows.length,
314
- });
315
- }
316
-
317
- const missingWordCountRows = rows.filter(row => row.word_count == null);
318
- if (missingWordCountRows.length > 0) {
319
- warnings.push({
320
- type: "missing_word_count",
321
- message: `${missingWordCountRows.length} scene(s) are missing word_count; estimated_word_count may be low.`,
322
- count: missingWordCountRows.length,
323
- });
324
- }
325
-
326
- const blockers = [];
327
- if (strictness === "fail" && staleRows.length > 0) {
328
- blockers.push({
329
- code: "STALE_METADATA",
330
- message: `${staleRows.length} scene(s) are marked metadata_stale.`,
331
- scene_ids: staleRows.map(row => row.scene_id),
332
- });
333
- }
334
-
335
- const estimatedWordCount = rows.reduce((sum, row) => {
336
- const count = Number(row.word_count);
337
- return sum + (Number.isFinite(count) ? count : 0);
338
- }, 0);
339
- const resolvedRecipientName = profile === "beta_reader_personalized"
340
- ? normalizeRecipientDisplayName(recipient_name)
341
- : undefined;
342
-
343
- const safeBundleName = slugifyBundleName(bundle_name || `${project_id}-${profile}`);
344
- const appliedFilters = {
345
- ...(part !== undefined ? { part } : {}),
346
- ...(chapter !== undefined ? { chapter } : {}),
347
- ...(tag ? { tag } : {}),
348
- ...(Array.isArray(scene_ids) ? { scene_ids } : {}),
349
- };
350
-
351
- return {
352
- ok: true,
353
- profile,
354
- resolved_scope: {
355
- project_id,
356
- filters: appliedFilters,
357
- options: {
358
- include_scene_ids: Boolean(include_scene_ids),
359
- include_metadata_sidebar: Boolean(include_metadata_sidebar),
360
- include_paragraph_anchors: Boolean(include_paragraph_anchors),
361
- ...(resolvedRecipientName ? { recipient_name: resolvedRecipientName } : {}),
362
- },
363
- },
364
- ordering: rows.map(row => ({
365
- scene_id: row.scene_id,
366
- project_id: row.project_id,
367
- title: row.title,
368
- part: row.part,
369
- chapter: row.chapter,
370
- timeline_position: row.timeline_position,
371
- metadata_stale: Number(row.metadata_stale) === 1,
372
- })),
373
- summary: {
374
- scene_count: rows.length,
375
- estimated_word_count: estimatedWordCount,
376
- excluded_scene_ids: excludedSceneIds,
377
- },
378
- warnings,
379
- warning_summary: buildWarningSummary(warnings),
380
- strictness_result: {
381
- strictness,
382
- can_proceed: blockers.length === 0,
383
- blockers,
384
- },
385
- planned_outputs: [
386
- ...(format === "markdown" || format === "both" ? [`${safeBundleName}.md`] : []),
387
- ...(format === "pdf" || format === "both" ? [`${safeBundleName}.pdf`] : []),
388
- ...(profile === "beta_reader_personalized"
389
- ? [
390
- `${safeBundleName}.notice.md`,
391
- `${safeBundleName}.feedback-form.md`,
392
- ]
393
- : []),
394
- `${safeBundleName}.manifest.json`,
395
- ],
396
- };
397
- }
398
-
399
- function loadBundleSceneRows(dbHandle, projectId, sceneIds) {
400
- if (!Array.isArray(sceneIds) || sceneIds.length === 0) return [];
401
- const rows = [];
402
- // 900 is safely below SQLite's per-query bound of 999 host parameters
403
- // (one slot is used by the project_id binding, leaving 998 for scene_id placeholders;
404
- // 900 gives extra headroom for any future additions to the query).
405
- const chunkSize = 900;
406
- for (let offset = 0; offset < sceneIds.length; offset += chunkSize) {
407
- const chunk = sceneIds.slice(offset, offset + chunkSize);
408
- const placeholders = chunk.map(() => "?").join(",");
409
- const chunkRows = dbHandle.prepare(`
410
- SELECT
411
- scene_id,
412
- project_id,
413
- title,
414
- part,
415
- chapter,
416
- timeline_position,
417
- logline,
418
- pov,
419
- save_the_cat_beat,
420
- file_path
421
- FROM scenes
422
- WHERE project_id = ? AND scene_id IN (${placeholders})
423
- `).all(projectId, ...chunk);
424
- rows.push(...chunkRows);
425
- }
426
-
427
- const rowMap = new Map(rows.map(row => [row.scene_id, row]));
428
- const orderedRows = [];
429
- const missingSceneIds = [];
430
-
431
- for (const sceneId of sceneIds) {
432
- const row = rowMap.get(sceneId);
433
- if (row) {
434
- orderedRows.push(row);
435
- } else {
436
- missingSceneIds.push(sceneId);
437
- }
438
- }
439
-
440
- if (missingSceneIds.length > 0) {
441
- throw new ReviewBundlePlanError(
442
- "MISSING_SCENE_ROWS",
443
- `Bundle includes ${missingSceneIds.length} scene(s) that could not be loaded from the database.`,
444
- {
445
- project_id: projectId,
446
- missing_scene_ids: missingSceneIds,
447
- requested_scene_count: sceneIds.length,
448
- resolved_scene_count: orderedRows.length,
449
- }
450
- );
451
- }
452
-
453
- return orderedRows;
454
- }
455
-
456
- function normalizeRelativePath(inputPath) {
457
- return String(inputPath).replace(/\\/g, "/").replace(/^\.\//, "");
458
- }
459
-
460
- function resolveSceneFilePath(filePath, { syncDir } = {}) {
461
- if (!filePath || !syncDir) return null;
462
-
463
- const normalizedSyncDir = path.resolve(syncDir);
464
- let realSyncDir;
465
- try {
466
- realSyncDir = fs.realpathSync.native(normalizedSyncDir);
467
- } catch {
468
- return null;
469
- }
470
-
471
- const rel = normalizeRelativePath(filePath);
472
- const candidates = [];
473
-
474
- if (path.isAbsolute(filePath)) {
475
- // Canonicalize the absolute path (resolve symlinks) so the boundary check
476
- // works correctly even when syncDir itself contains a symlink component
477
- // (e.g. macOS /var → /private/var or /tmp → /private/tmp).
478
- const resolvedAbsolute = path.resolve(filePath);
479
- let canonicalAbsolute;
480
-
481
- if (fs.existsSync(resolvedAbsolute)) {
482
- try {
483
- canonicalAbsolute = fs.realpathSync.native(resolvedAbsolute);
484
- } catch {
485
- // Cannot canonicalize — skip this candidate.
486
- }
487
- } else {
488
- // File doesn't exist yet; walk up to the nearest existing ancestor,
489
- // canonicalize that, then reconstruct the full path.
490
- let ancestor = resolvedAbsolute;
491
- const segments = [];
492
- while (!fs.existsSync(ancestor)) {
493
- const parent = path.dirname(ancestor);
494
- if (parent === ancestor) { ancestor = null; break; }
495
- segments.unshift(path.basename(ancestor));
496
- ancestor = parent;
497
- }
498
- if (ancestor) {
499
- try {
500
- const realAncestor = fs.realpathSync.native(ancestor);
501
- canonicalAbsolute = path.resolve(realAncestor, ...segments);
502
- } catch {
503
- // Cannot canonicalize.
504
- }
505
- }
506
- }
507
-
508
- if (canonicalAbsolute) {
509
- const relFromSync = path.relative(realSyncDir, canonicalAbsolute);
510
- if (!relFromSync.startsWith("..") && !path.isAbsolute(relFromSync)) {
511
- candidates.push(canonicalAbsolute);
512
- }
513
- }
514
- } else {
515
- candidates.push(path.resolve(realSyncDir, rel));
516
- // Scrivener External Folder Sync sometimes stores paths prefixed with
517
- // "sync/" (the name of the sync folder itself) relative to the project
518
- // root. Strip that prefix so we can find the file within realSyncDir.
519
- if (rel === "sync" || rel.startsWith("sync/")) {
520
- candidates.push(path.resolve(realSyncDir, rel.replace(/^sync\/?/, "")));
521
- }
522
- }
523
-
524
- for (const candidate of candidates) {
525
- if (!fs.existsSync(candidate)) {
526
- // Before returning a non-existent path, verify it is still inside realSyncDir.
527
- // A relative filePath with .. segments could otherwise escape the boundary.
528
- const relFromSync = path.relative(realSyncDir, candidate);
529
- if (!relFromSync.startsWith("..") && !path.isAbsolute(relFromSync)) {
530
- return candidate;
531
- }
532
- continue;
533
- }
534
-
535
- // File exists: validate realpath stays inside syncDir to catch symlink escapes.
536
- // (For absolute paths this is already canonicalized; for relative paths, verify.)
537
- try {
538
- const realCandidate = fs.realpathSync.native(candidate);
539
- const relReal = path.relative(realSyncDir, realCandidate);
540
- if (!relReal.startsWith("..") && !path.isAbsolute(relReal)) {
541
- return realCandidate;
542
- }
543
- } catch {
544
- continue;
545
- }
546
- }
547
-
548
- return null;
549
- }
550
-
551
- function readProse(filePath, { syncDir } = {}) {
552
- const resolvedPath = resolveSceneFilePath(filePath, { syncDir });
553
- if (!resolvedPath) return null;
554
- try {
555
- const raw = fs.readFileSync(resolvedPath, "utf8");
556
- return matter(raw).content.trim();
557
- } catch (error) {
558
- const errorCode = error && typeof error === "object" && "code" in error && typeof error.code === "string"
559
- ? error.code
560
- : null;
561
- const errorMessage = error instanceof Error ? error.message : String(error);
562
- throw new ReviewBundlePlanError(
563
- "SCENE_PROSE_READ_FAILED",
564
- `Failed to read scene prose from ${resolvedPath}.`,
565
- {
566
- file_path: filePath,
567
- resolved_path: resolvedPath,
568
- error_code: errorCode,
569
- cause: errorMessage,
570
- }
571
- );
572
- }
573
- }
574
-
575
- function renderSceneBlock(scene, options) {
576
- const {
577
- profile,
578
- includeSceneIds,
579
- includeMetadataSidebar,
580
- includeParagraphAnchors,
581
- } = options;
582
-
583
- const title = scene.title || scene.scene_id;
584
- const sceneHeading = includeSceneIds
585
- ? `## ${escapeMarkdown(title)} (${escapeMarkdown(scene.scene_id)})`
586
- : `## ${escapeMarkdown(title)}`;
587
-
588
- const parts = [sceneHeading];
589
-
590
- if (profile === "outline_discussion") {
591
- const summaryParts = [];
592
- if (scene.pov) summaryParts.push(`POV: ${scene.pov}`);
593
- if (scene.save_the_cat_beat) summaryParts.push(`Beat: ${scene.save_the_cat_beat}`);
594
- if (scene.part != null) summaryParts.push(`Part: ${scene.part}`);
595
- if (scene.chapter != null) summaryParts.push(`Chapter: ${scene.chapter}`);
596
- if (summaryParts.length > 0) {
597
- parts.push(`_${escapeMarkdown(summaryParts.join(" | "))}_`);
598
- }
599
- if (scene.logline) {
600
- parts.push(escapeMarkdown(scene.logline.trim()));
601
- }
602
- return parts.join("\n\n");
603
- }
604
-
605
- if (includeMetadataSidebar) {
606
- const sidebar = [
607
- scene.part != null ? `part: ${scene.part}` : null,
608
- scene.chapter != null ? `chapter: ${scene.chapter}` : null,
609
- scene.timeline_position != null ? `timeline_position: ${scene.timeline_position}` : null,
610
- scene.pov ? `pov: ${escapeMarkdown(scene.pov)}` : null,
611
- scene.save_the_cat_beat ? `beat: ${escapeMarkdown(scene.save_the_cat_beat)}` : null,
612
- ].filter(Boolean);
613
- if (sidebar.length > 0) {
614
- parts.push(`> ${sidebar.join(" \\\n> ")}`);
615
- }
616
- }
617
-
618
- const prose = scene.prose ?? "";
619
- if (!includeParagraphAnchors || prose.length === 0) {
620
- parts.push(prose);
621
- return parts.join("\n\n");
622
- }
623
-
624
- const paragraphs = prose
625
- .split(/\n\s*\n/g)
626
- .map(p => p.trim())
627
- .filter(Boolean);
628
- // Sanitize scene_id for safe embedding in an HTML comment: restrict to
629
- // alphanumerics, hyphens, underscores, and dots to prevent "-->" or other
630
- // sequences from prematurely terminating the comment.
631
- const safeSceneId = scene.scene_id.replace(/[^a-zA-Z0-9\-_.]/g, "_");
632
- const anchoredParagraphs = paragraphs.map((paragraph, index) => {
633
- return `<!-- ${safeSceneId}:p${index + 1} -->\n${paragraph}`;
634
- });
635
- parts.push(anchoredParagraphs.join("\n\n"));
636
- return parts.join("\n\n");
637
- }
638
-
639
- export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
640
- const profile = plan.profile;
641
- const includeSceneIds = Boolean(plan.resolved_scope?.options?.include_scene_ids);
642
- const includeMetadataSidebar = Boolean(plan.resolved_scope?.options?.include_metadata_sidebar);
643
- const includeParagraphAnchors = Boolean(plan.resolved_scope?.options?.include_paragraph_anchors);
644
- // Prefer explicitly threaded syncDir; fall back to env (with "./sync" default matching index.js).
645
- // Prefer explicitly threaded syncDir; fall back to env.
646
- // No further fallback: if syncDir is null, resolveSceneFilePath returns null
647
- // and SCENE_PROSE_READ_FAILED is thrown, making misconfiguration explicit.
648
- const syncDir = syncDirOpt ?? process.env.WRITING_SYNC_DIR ?? null;
649
-
650
- const sceneIds = plan.ordering.map(row => row.scene_id);
651
- const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
652
- const sections = [];
653
- const recipientName = plan.resolved_scope?.options?.recipient_name;
654
- const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
655
-
656
- const headerLines = [
657
- `# Review Bundle: ${escapeMarkdown(plan.resolved_scope.project_id)}`,
658
- "",
659
- `- Profile: ${profile}`,
660
- ...(profile === "beta_reader_personalized"
661
- ? [`- Recipient: ${escapeMarkdown(recipientDisplayName)}`]
662
- : []),
663
- `- Generated at: ${generatedAt ?? new Date().toISOString()}`,
664
- `- Scene count: ${plan.summary.scene_count}`,
665
- ];
666
- sections.push(headerLines.join("\n"));
667
-
668
- if (profile === "beta_reader_personalized") {
669
- sections.push(
670
- [
671
- "## Usage Notice",
672
- "",
673
- "This beta-reader draft is intended for private review and feedback.",
674
- "Please do not redistribute without explicit author permission.",
675
- ].join("\n")
676
- );
677
- }
678
-
679
- for (const scene of rows) {
680
- let prose = "";
681
- if (profile === "editor_detailed" || profile === "beta_reader_personalized") {
682
- const resolved = readProse(scene.file_path, { syncDir });
683
- if (resolved === null) {
684
- throw new ReviewBundlePlanError(
685
- "SCENE_PROSE_READ_FAILED",
686
- `Scene prose is unavailable for scene ${scene.scene_id}: file_path is null or could not be resolved within syncDir.`,
687
- {
688
- scene_id: scene.scene_id,
689
- file_path: scene.file_path ?? null,
690
- sync_dir: syncDir,
691
- }
692
- );
693
- }
694
- prose = resolved;
695
- }
696
- const withProse = { ...scene, prose };
697
- sections.push(renderSceneBlock(withProse, {
698
- profile,
699
- includeSceneIds,
700
- includeMetadataSidebar,
701
- includeParagraphAnchors,
702
- }));
703
- }
704
-
705
- return sections.join("\n\n---\n\n").trim() + "\n";
706
- }
707
-
708
- /**
709
- * Render a review bundle plan to PDF format using pdfkit.
710
- * Returns a buffer containing the PDF document.
711
- */
712
- export function renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
713
- const profile = plan.profile;
714
- const includeSceneIds = Boolean(plan.resolved_scope?.options?.include_scene_ids);
715
- const syncDir = syncDirOpt ?? process.env.WRITING_SYNC_DIR ?? null;
716
-
717
- const sceneIds = plan.ordering.map(row => row.scene_id);
718
- const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
719
- const recipientName = plan.resolved_scope?.options?.recipient_name;
720
- const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
721
-
722
- // Create PDF document in memory (we'll pipe to buffer)
723
- const doc = new PDFDocument({
724
- size: "Letter",
725
- margin: 50,
726
- });
727
-
728
- // Register listeners before any content is written so render-time errors
729
- // always reject the returned Promise.
730
- const chunks = [];
731
- return new Promise((resolve, reject) => {
732
- let settled = false;
733
- const fail = (err) => {
734
- if (settled) return;
735
- settled = true;
736
- reject(err);
737
- };
738
-
739
- doc.on("data", chunk => chunks.push(chunk));
740
- doc.on("error", fail);
741
- doc.on("end", () => {
742
- if (settled) return;
743
- settled = true;
744
- resolve(Buffer.concat(chunks));
745
- });
746
-
747
- try {
748
- // Title and metadata
749
- doc.fontSize(24).font("Helvetica-Bold").text(`Review Bundle: ${plan.resolved_scope.project_id}`, { align: "left" });
750
- doc.moveDown(0.5);
751
- doc.fontSize(11).font("Helvetica");
752
- doc.text(`Profile: ${profile}`, { align: "left" });
753
- if (profile === "beta_reader_personalized") {
754
- doc.text(`Recipient: ${recipientDisplayName}`, { align: "left" });
755
- }
756
- doc.text(`Generated: ${generatedAt ?? new Date().toISOString()}`, { align: "left" });
757
- doc.text(`Scenes: ${plan.summary.scene_count}`, { align: "left" });
758
- doc.moveDown();
759
-
760
- // Usage notice for beta profile
761
- if (profile === "beta_reader_personalized") {
762
- doc.fontSize(12).font("Helvetica-Bold").text("Usage Notice", { align: "left" });
763
- doc.moveDown(0.3);
764
- const noticeWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
765
- doc.fontSize(10).font("Helvetica");
766
- doc.text("This beta-reader draft is intended for private review and feedback. Please do not redistribute without explicit author permission.", {
767
- align: "left",
768
- width: noticeWidth,
769
- });
770
- doc.moveDown();
771
- }
772
-
773
- // Render scenes
774
- for (const scene of rows) {
775
- // Scene heading
776
- doc.fontSize(14).font("Helvetica-Bold");
777
- let heading = scene.title || scene.scene_id;
778
- if (includeSceneIds) {
779
- heading += ` [${scene.scene_id}]`;
780
- }
781
- doc.text(heading, { align: "left" });
782
- doc.moveDown(0.2);
783
-
784
- // Scene metadata (one-liner)
785
- const metaParts = [];
786
- if (scene.pov) metaParts.push(`POV: ${scene.pov}`);
787
- if (scene.save_the_cat_beat) metaParts.push(`Beat: ${scene.save_the_cat_beat}`);
788
- if (metaParts.length > 0) {
789
- const metaWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
790
- doc.fontSize(9).font("Helvetica-Oblique");
791
- doc.text(metaParts.join(" • "), { align: "left", width: metaWidth });
792
- doc.font("Helvetica");
793
- doc.moveDown(0.2);
794
- }
795
-
796
- // Logline
797
- if (scene.logline) {
798
- doc.fontSize(10).font("Helvetica-Oblique");
799
- const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
800
- doc.text(`"${scene.logline}"`, { align: "left", width: textWidth });
801
- doc.moveDown(0.3);
802
- }
803
-
804
- // Prose (only for detailed/beta profiles)
805
- if (profile === "editor_detailed" || profile === "beta_reader_personalized") {
806
- let prose = "";
807
- const resolved = readProse(scene.file_path, { syncDir });
808
- if (resolved === null) {
809
- throw new ReviewBundlePlanError(
810
- "SCENE_PROSE_READ_FAILED",
811
- `Scene prose is unavailable for scene ${scene.scene_id}: file_path is null or could not be resolved within syncDir.`,
812
- {
813
- scene_id: scene.scene_id,
814
- file_path: scene.file_path ?? null,
815
- sync_dir: syncDir,
816
- }
817
- );
818
- }
819
- prose = resolved;
820
-
821
- const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
822
- doc.fontSize(10).font("Helvetica");
823
- doc.text(prose, {
824
- align: "left",
825
- width: textWidth,
826
- lineGap: 3,
827
- });
828
- }
829
-
830
- doc.moveDown(0.5);
831
- // Add page break between scenes only for prose-including profiles where
832
- // clear scene separation matters. For outline_discussion, let content flow.
833
- const includesProse = profile === "editor_detailed" || profile === "beta_reader_personalized";
834
- if (includesProse && scene !== rows[rows.length - 1]) {
835
- doc.addPage();
836
- }
837
- }
838
-
839
- doc.end();
840
- } catch (error) {
841
- fail(error);
842
- try {
843
- doc.end();
844
- } catch {
845
- // Ignore errors from end() during failure cleanup.
846
- }
847
- }
848
- });
849
- }
850
-
851
- export async function createReviewBundleArtifacts(dbHandle, {
852
- plan,
853
- output_dir,
854
- source_commit = null,
855
- syncDir,
856
- }) {
857
- if (!output_dir) {
858
- throw new ReviewBundlePlanError("INVALID_OUTPUT_DIR", "output_dir is required.");
859
- }
860
-
861
- const normalizedOutputDir = path.resolve(output_dir);
862
- if (fs.existsSync(normalizedOutputDir)) {
863
- if (!fs.statSync(normalizedOutputDir).isDirectory()) {
864
- throw new ReviewBundlePlanError(
865
- "INVALID_OUTPUT_DIR",
866
- `output_dir exists but is not a directory: ${normalizedOutputDir}`
867
- );
868
- }
869
- } else {
870
- fs.mkdirSync(normalizedOutputDir, { recursive: true });
871
- }
872
- try {
873
- fs.accessSync(normalizedOutputDir, fs.constants.W_OK);
874
- } catch {
875
- throw new ReviewBundlePlanError(
876
- "INVALID_OUTPUT_DIR",
877
- `output_dir is not writable: ${normalizedOutputDir}`
878
- );
879
- }
880
-
881
- const noticeFileName = plan.planned_outputs.find(name => name.endsWith(".notice.md")) ?? null;
882
- const feedbackFileName = plan.planned_outputs.find(name => name.endsWith(".feedback-form.md")) ?? null;
883
- // Derive which outputs to write from the plan itself, not from the format param,
884
- // so plan and artifacts always stay in sync.
885
- const markdownFileName = plan.planned_outputs.find(
886
- name => name.endsWith(".md") && !name.endsWith(".notice.md") && !name.endsWith(".feedback-form.md")
887
- ) ?? null;
888
- const pdfFileName = plan.planned_outputs.find(name => name.endsWith(".pdf")) ?? null;
889
- const manifestFileName = plan.planned_outputs.find(name => name.endsWith(".manifest.json"));
890
-
891
- if (!manifestFileName) {
892
- throw new ReviewBundlePlanError(
893
- "INVALID_PLAN_OUTPUTS",
894
- "Plan is missing expected manifest filename."
895
- );
896
- }
897
-
898
- if (!markdownFileName && !pdfFileName) {
899
- throw new ReviewBundlePlanError(
900
- "INVALID_PLAN_OUTPUTS",
901
- "Plan has no primary bundle output (neither .md nor .pdf) in planned_outputs."
902
- );
903
- }
904
-
905
- const markdownPath = markdownFileName ? resolveOutputFilePath(normalizedOutputDir, markdownFileName) : null;
906
- const pdfPath = pdfFileName ? resolveOutputFilePath(normalizedOutputDir, pdfFileName) : null;
907
- const manifestPath = resolveOutputFilePath(normalizedOutputDir, manifestFileName);
908
- const noticePath = noticeFileName ? resolveOutputFilePath(normalizedOutputDir, noticeFileName) : null;
909
- const feedbackPath = feedbackFileName ? resolveOutputFilePath(normalizedOutputDir, feedbackFileName) : null;
910
-
911
- const generatedAt = new Date().toISOString();
912
-
913
- // Render markdown if needed
914
- const markdown = markdownPath ? renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir }) : null;
915
-
916
- // Render PDF if needed
917
- let pdfBuffer = null;
918
- if (pdfPath) {
919
- pdfBuffer = await renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir });
920
- }
921
-
922
- const recipientName = plan.resolved_scope?.options?.recipient_name;
923
- const betaNotice = plan.profile === "beta_reader_personalized"
924
- ? renderBetaNoticeMarkdown({ projectId: plan.resolved_scope.project_id, recipientName })
925
- : null;
926
- const betaFeedbackForm = plan.profile === "beta_reader_personalized"
927
- ? renderBetaFeedbackFormMarkdown({ projectId: plan.resolved_scope.project_id, recipientName, generatedAt })
928
- : null;
929
-
930
- // Use the bundle ID from whichever primary file exists
931
- const bundleIdFileName = markdownFileName || pdfFileName;
932
- const manifest = {
933
- bundle_id: path.basename(bundleIdFileName, path.extname(bundleIdFileName)),
934
- profile: plan.profile,
935
- generated_at: generatedAt,
936
- provenance: {
937
- source_commit: source_commit ?? null,
938
- project_id: plan.resolved_scope.project_id,
939
- },
940
- summary: plan.summary,
941
- warning_summary: plan.warning_summary,
942
- warnings: plan.warnings,
943
- resolved_scope: plan.resolved_scope,
944
- scene_ids: plan.ordering.map(row => row.scene_id),
945
- };
946
-
947
- for (const outputPath of [markdownPath, pdfPath, manifestPath, noticePath, feedbackPath].filter(Boolean)) {
948
- try {
949
- const stat = fs.lstatSync(outputPath);
950
- if (stat.isSymbolicLink()) {
951
- throw new ReviewBundlePlanError(
952
- "INVALID_OUTPUT_PATH",
953
- `Refusing to write: target path is a symlink: ${outputPath}`
954
- );
955
- }
956
- if (!stat.isFile()) {
957
- throw new ReviewBundlePlanError(
958
- "INVALID_OUTPUT_PATH",
959
- `Refusing to write: target path exists but is not a regular file: ${outputPath}`
960
- );
961
- }
962
- } catch (error) {
963
- if (error instanceof ReviewBundlePlanError) throw error;
964
- if (error?.code !== "ENOENT") throw error;
965
- // ENOENT — file doesn't exist yet, which is the expected case.
966
- // Note: there is an inherent TOCTOU window between this lstat check and the
967
- // writeFileSync below. This is acceptable for a local tool where the caller
968
- // controls the output directory.
969
- }
970
- }
971
-
972
- if (markdownPath && markdown != null) {
973
- fs.writeFileSync(markdownPath, markdown, "utf8");
974
- }
975
- if (pdfPath && pdfBuffer != null) {
976
- fs.writeFileSync(pdfPath, pdfBuffer);
977
- }
978
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
979
- if (noticePath && betaNotice != null) {
980
- fs.writeFileSync(noticePath, betaNotice, "utf8");
981
- }
982
- if (feedbackPath && betaFeedbackForm != null) {
983
- fs.writeFileSync(feedbackPath, betaFeedbackForm, "utf8");
984
- }
985
-
986
- return {
987
- bundle_id: manifest.bundle_id,
988
- output_paths: {
989
- ...(markdownPath ? { bundle_markdown: markdownPath } : {}),
990
- ...(pdfPath ? { bundle_pdf: pdfPath } : {}),
991
- manifest_json: manifestPath,
992
- ...(noticePath ? { notice_md: noticePath } : {}),
993
- ...(feedbackPath ? { feedback_form_md: feedbackPath } : {}),
994
- },
995
- generated_at: generatedAt,
996
- };
997
-
998
- }
1
+ export {
2
+ REVIEW_BUNDLE_PROFILES,
3
+ REVIEW_BUNDLE_STRICTNESS,
4
+ REVIEW_BUNDLE_FORMATS,
5
+ ReviewBundlePlanError,
6
+ buildReviewBundlePlan,
7
+ } from "./review-bundles-planner.js";
8
+
9
+ export {
10
+ renderReviewBundleMarkdown,
11
+ renderReviewBundlePdf,
12
+ } from "./review-bundles-renderer.js";
13
+
14
+ export { createReviewBundleArtifacts } from "./review-bundles-writer.js";