@hanna84/mcp-writing 3.5.0 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,9 +4,16 @@ 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
+ #### [v3.5.1](https://github.com/hannasdev/mcp-writing/compare/v3.5.0...v3.5.1)
8
+
9
+ - Fix beta accountability follow-ups and test coverage [`#183`](https://github.com/hannasdev/mcp-writing/pull/183)
10
+
7
11
  #### [v3.5.0](https://github.com/hannasdev/mcp-writing/compare/v3.4.4...v3.5.0)
8
12
 
13
+ > 8 May 2026
14
+
9
15
  - feat: add accountable beta-reader bundle fingerprinting [`#182`](https://github.com/hannasdev/mcp-writing/pull/182)
16
+ - Release 3.5.0 [`6134dfc`](https://github.com/hannasdev/mcp-writing/commit/6134dfc17e998c118f5fdbb68f1a26e3ba673f05)
10
17
 
11
18
  #### [v3.4.4](https://github.com/hannasdev/mcp-writing/compare/v3.4.3...v3.4.4)
12
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.5.0",
3
+ "version": "3.5.1",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -335,16 +335,23 @@ export function buildReviewBundlePlan(dbHandle, {
335
335
  : undefined;
336
336
 
337
337
  const safeBundleName = slugifyBundleName(bundle_name || `${project_id}-${profile}`);
338
+ const normalizedSceneIds = Array.isArray(scene_ids)
339
+ ? Array.from(new Set(scene_ids.map(sceneId => String(sceneId)))).sort()
340
+ : undefined;
338
341
  const appliedFilters = {
339
342
  ...(part !== undefined ? { part } : {}),
340
343
  ...(chapter !== undefined ? { chapter } : {}),
341
344
  ...(Array.isArray(normalizedChapters) ? { chapters: normalizedChapters } : {}),
342
345
  ...(tag ? { tag } : {}),
343
- ...(Array.isArray(scene_ids) ? { scene_ids } : {}),
346
+ ...(Array.isArray(normalizedSceneIds) ? { scene_ids: normalizedSceneIds } : {}),
344
347
  };
345
348
  const resolvedBetaAccountability = profile === "beta_reader_personalized"
346
349
  ? Boolean(beta_accountability ?? true)
347
350
  : false;
351
+ const isBetaProfile = profile === "beta_reader_personalized";
352
+ const resolvedIncludeSceneIds = isBetaProfile ? false : Boolean(include_scene_ids);
353
+ const resolvedIncludeMetadataSidebar = isBetaProfile ? false : Boolean(include_metadata_sidebar);
354
+ const resolvedIncludeParagraphAnchors = isBetaProfile ? false : Boolean(include_paragraph_anchors);
348
355
 
349
356
  return {
350
357
  ok: true,
@@ -353,9 +360,9 @@ export function buildReviewBundlePlan(dbHandle, {
353
360
  project_id,
354
361
  filters: appliedFilters,
355
362
  options: {
356
- include_scene_ids: Boolean(include_scene_ids),
357
- include_metadata_sidebar: Boolean(include_metadata_sidebar),
358
- include_paragraph_anchors: Boolean(include_paragraph_anchors),
363
+ include_scene_ids: resolvedIncludeSceneIds,
364
+ include_metadata_sidebar: resolvedIncludeMetadataSidebar,
365
+ include_paragraph_anchors: resolvedIncludeParagraphAnchors,
359
366
  beta_accountability: resolvedBetaAccountability,
360
367
  ...(resolvedRecipientName ? { recipient_name: resolvedRecipientName } : {}),
361
368
  },
@@ -346,11 +346,16 @@ function buildPageFingerprintToken({ seedHash, pageNumber }) {
346
346
  return `BR-${digest}-P${String(pageNumber).padStart(3, "0")}`;
347
347
  }
348
348
 
349
+ function sanitizeFooterRecipientDisplayName(recipientDisplayName) {
350
+ return String(recipientDisplayName ?? "").replaceAll("|", "/");
351
+ }
352
+
349
353
  export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
350
354
  const profile = plan.profile;
351
- const includeSceneIds = Boolean(plan.resolved_scope?.options?.include_scene_ids);
352
- const includeMetadataSidebar = Boolean(plan.resolved_scope?.options?.include_metadata_sidebar);
353
- const includeParagraphAnchors = Boolean(plan.resolved_scope?.options?.include_paragraph_anchors);
355
+ const isBetaProfile = profile === "beta_reader_personalized";
356
+ const includeSceneIds = isBetaProfile ? false : Boolean(plan.resolved_scope?.options?.include_scene_ids);
357
+ const includeMetadataSidebar = isBetaProfile ? false : Boolean(plan.resolved_scope?.options?.include_metadata_sidebar);
358
+ const includeParagraphAnchors = isBetaProfile ? false : Boolean(plan.resolved_scope?.options?.include_paragraph_anchors);
354
359
  // Prefer explicitly threaded syncDir; fall back to env.
355
360
  // No further fallback: if syncDir is null, resolveSceneFilePath returns null
356
361
  // and SCENE_PROSE_READ_FAILED is thrown, making misconfiguration explicit.
@@ -365,12 +370,13 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
365
370
  const headerLines = [
366
371
  `# Review Bundle: ${escapeMarkdown(plan.resolved_scope.project_id)}`,
367
372
  "",
368
- `- Profile: ${profile}`,
373
+ ...(profile !== "beta_reader_personalized" ? [`- Profile: ${profile}`] : []),
369
374
  ...(profile === "beta_reader_personalized"
370
375
  ? [`- Recipient: ${escapeMarkdown(recipientDisplayName)}`]
371
376
  : []),
372
- `- Generated at: ${generatedAt ?? new Date().toISOString()}`,
373
- `- Scene count: ${plan.summary.scene_count}`,
377
+ ...(profile !== "beta_reader_personalized"
378
+ ? [`- Generated at: ${generatedAt ?? new Date().toISOString()}`, `- Scene count: ${plan.summary.scene_count}`]
379
+ : []),
374
380
  ];
375
381
  sections.push(headerLines.join("\n"));
376
382
 
@@ -421,13 +427,24 @@ export function renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir: sy
421
427
 
422
428
  export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
423
429
  const profile = plan.profile;
424
- const includeSceneIds = Boolean(plan.resolved_scope?.options?.include_scene_ids);
430
+ const includeSceneIds = profile === "beta_reader_personalized"
431
+ ? false
432
+ : Boolean(plan.resolved_scope?.options?.include_scene_ids);
425
433
  const syncDir = syncDirOpt ?? process.env.WRITING_SYNC_DIR ?? null;
434
+ const isBetaProfile = profile === "beta_reader_personalized";
435
+ const proseFontSize = isBetaProfile ? 8 : 10;
436
+ const proseLineGap = isBetaProfile ? 3.2 : 3;
437
+ const bodyFont = profile === "beta_reader_personalized" ? "Times-Roman" : "Helvetica";
438
+ const coverHeadingFont = profile === "beta_reader_personalized" ? "Times-Bold" : "Helvetica-Bold";
439
+ // Beta scene headings intentionally use body font (non-bold) per product direction.
440
+ const sceneHeadingFont = isBetaProfile ? bodyFont : coverHeadingFont;
441
+ const metaFont = profile === "beta_reader_personalized" ? "Times-Italic" : "Helvetica-Oblique";
426
442
 
427
443
  const sceneIds = plan.ordering.map(row => row.scene_id);
428
444
  const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
429
445
  const recipientName = plan.resolved_scope?.options?.recipient_name;
430
446
  const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
447
+ const footerRecipientDisplayName = sanitizeFooterRecipientDisplayName(recipientDisplayName);
431
448
  const betaAccountabilityEnabled = profile === "beta_reader_personalized"
432
449
  && Boolean(plan.resolved_scope?.options?.beta_accountability);
433
450
  const effectiveGeneratedAt = generatedAt ?? new Date().toISOString();
@@ -441,7 +458,8 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
441
458
  const pdfOptions = profile === "beta_reader_personalized"
442
459
  ? {
443
460
  size: [432, 648], // 6x9in in PDF points
444
- margins: { top: 64, right: 58, bottom: 72, left: 58 },
461
+ // Extra bottom margin reserves clear space above the accountability footer.
462
+ margins: { top: 64, right: 58, bottom: 96, left: 58 },
445
463
  autoFirstPage: false,
446
464
  }
447
465
  : {
@@ -455,23 +473,30 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
455
473
 
456
474
  const drawAccountabilityFooter = () => {
457
475
  if (!betaAccountabilityEnabled || !fingerprintSeedHash) return;
476
+ const previousX = doc.x;
477
+ const previousY = doc.y;
458
478
  pageNumber += 1;
459
479
  const token = buildPageFingerprintToken({
460
480
  seedHash: fingerprintSeedHash,
461
481
  pageNumber,
462
482
  });
463
483
  pageTokens.push({ page: pageNumber, token });
464
- const footerY = doc.page.height - doc.page.margins.bottom - 12;
465
- const footerWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
466
- const footerText = `For: ${recipientDisplayName} | Fingerprint: ${token} | Page ${pageNumber}`;
484
+ const footerY = doc.page.height - 42;
485
+ const footerText = `For: ${footerRecipientDisplayName} | Fingerprint: ${token}`;
486
+ const pageNumberText = String(pageNumber);
467
487
  doc.save();
468
- doc.font("Helvetica").fontSize(8).fillColor("#555555");
469
- doc.text(footerText, doc.page.margins.left, footerY, {
470
- width: footerWidth,
471
- align: "left",
472
- lineBreak: false,
473
- });
488
+ doc.font("Times-Roman").fontSize(8).fillColor("#555555");
489
+ // Draw footer in no-wrap mode to avoid layout flow side effects.
490
+ doc.text(footerText, doc.page.margins.left, footerY, { lineBreak: false });
491
+ const pageNumberWidth = doc.widthOfString(pageNumberText);
492
+ const pageNumberX = (doc.page.width - pageNumberWidth) / 2;
493
+ doc.text(pageNumberText, pageNumberX, doc.page.height - 24, { lineBreak: false });
474
494
  doc.restore();
495
+ // Restore prose style so auto-flowed text keeps consistent typography
496
+ // on pages added during long text rendering.
497
+ doc.font(bodyFont).fontSize(proseFontSize);
498
+ doc.x = previousX;
499
+ doc.y = previousY;
475
500
  };
476
501
  doc.on("pageAdded", drawAccountabilityFooter);
477
502
 
@@ -505,22 +530,25 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
505
530
 
506
531
  try {
507
532
  doc.addPage();
508
- doc.fontSize(24).font("Helvetica-Bold").text(`Review Bundle: ${plan.resolved_scope.project_id}`, { align: "left" });
533
+ doc.fontSize(24).font(coverHeadingFont).text(`Review Bundle: ${plan.resolved_scope.project_id}`, { align: "left" });
509
534
  doc.moveDown(0.5);
510
- doc.fontSize(11).font("Helvetica");
511
- doc.text(`Profile: ${profile}`, { align: "left" });
535
+ doc.fontSize(11).font(bodyFont);
536
+ if (profile !== "beta_reader_personalized") {
537
+ doc.text(`Profile: ${profile}`, { align: "left" });
538
+ }
512
539
  if (profile === "beta_reader_personalized") {
513
540
  doc.text(`Recipient: ${recipientDisplayName}`, { align: "left" });
541
+ } else {
542
+ doc.text(`Generated: ${effectiveGeneratedAt}`, { align: "left" });
543
+ doc.text(`Scenes: ${plan.summary.scene_count}`, { align: "left" });
514
544
  }
515
- doc.text(`Generated: ${effectiveGeneratedAt}`, { align: "left" });
516
- doc.text(`Scenes: ${plan.summary.scene_count}`, { align: "left" });
517
545
  doc.moveDown();
518
546
 
519
547
  if (profile === "beta_reader_personalized") {
520
- doc.fontSize(12).font("Helvetica-Bold").text("Usage Notice", { align: "left" });
548
+ doc.fontSize(12).font("Times-Bold").text("Usage Notice", { align: "left" });
521
549
  doc.moveDown(0.3);
522
550
  const noticeWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
523
- doc.fontSize(10).font("Helvetica");
551
+ doc.fontSize(10).font("Times-Roman");
524
552
  doc.text("This beta-reader draft is intended for private review and feedback. Please do not redistribute without explicit author permission.", {
525
553
  align: "left",
526
554
  width: noticeWidth,
@@ -529,22 +557,29 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
529
557
  }
530
558
 
531
559
  for (const scene of rows) {
532
- doc.fontSize(14).font("Helvetica-Bold");
560
+ if (isBetaProfile) {
561
+ // Give chapter titles generous vertical breathing room for a
562
+ // print-like opening feel before prose begins.
563
+ doc.moveDown(2.0);
564
+ }
565
+ doc.fontSize(isBetaProfile ? 13 : 14).font(sceneHeadingFont);
533
566
  let heading = scene.title || scene.scene_id;
534
567
  if (includeSceneIds) {
535
568
  heading += ` [${scene.scene_id}]`;
536
569
  }
537
- doc.text(heading, { align: "left" });
538
- doc.moveDown(0.2);
570
+ doc.text(heading, { align: isBetaProfile ? "center" : "left" });
571
+ doc.moveDown(isBetaProfile ? 1.6 : 0.2);
539
572
 
540
573
  const metaParts = [];
541
- if (scene.pov) metaParts.push(`POV: ${scene.pov}`);
542
- if (scene.save_the_cat_beat) metaParts.push(`Beat: ${scene.save_the_cat_beat}`);
574
+ if (profile !== "beta_reader_personalized") {
575
+ if (scene.pov) metaParts.push(`POV: ${scene.pov}`);
576
+ if (scene.save_the_cat_beat) metaParts.push(`Beat: ${scene.save_the_cat_beat}`);
577
+ }
543
578
  if (metaParts.length > 0) {
544
579
  const metaWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
545
- doc.fontSize(9).font("Helvetica-Oblique");
580
+ doc.fontSize(9).font(metaFont);
546
581
  doc.text(metaParts.join(" • "), { align: "left", width: metaWidth });
547
- doc.font("Helvetica");
582
+ doc.font(bodyFont);
548
583
  doc.moveDown(0.2);
549
584
  }
550
585
 
@@ -572,11 +607,11 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
572
607
  prose = resolved;
573
608
 
574
609
  const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
575
- doc.fontSize(10).font("Helvetica");
610
+ doc.fontSize(proseFontSize).font(bodyFont);
576
611
  doc.text(prose, {
577
612
  align: "left",
578
613
  width: textWidth,
579
- lineGap: profile === "beta_reader_personalized" ? 4.5 : 3,
614
+ lineGap: proseLineGap,
580
615
  });
581
616
  }
582
617