@hanna84/mcp-writing 3.4.4 → 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,23 @@ 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
+
11
+ #### [v3.5.0](https://github.com/hannasdev/mcp-writing/compare/v3.4.4...v3.5.0)
12
+
13
+ > 8 May 2026
14
+
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)
17
+
7
18
  #### [v3.4.4](https://github.com/hannasdev/mcp-writing/compare/v3.4.3...v3.4.4)
8
19
 
20
+ > 8 May 2026
21
+
9
22
  - chore(copilot): tune instructions based on analysis [`#181`](https://github.com/hannasdev/mcp-writing/pull/181)
23
+ - Release 3.4.4 [`c76d472`](https://github.com/hannasdev/mcp-writing/commit/c76d47214be566dd52d5260f97b29e078e1047b6)
10
24
 
11
25
  #### [v3.4.3](https://github.com/hannasdev/mcp-writing/compare/v3.4.2...v3.4.3)
12
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.4.4",
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",
@@ -1,5 +1,6 @@
1
1
  const MAX_SORT_VALUE = Number.MAX_SAFE_INTEGER;
2
2
  const MAX_SCENE_ID_FILTER_PARAMS = 900;
3
+ const MAX_CHAPTER_FILTER_PARAMS = 900;
3
4
 
4
5
  export const REVIEW_BUNDLE_PROFILES = ["outline_discussion", "editor_detailed", "beta_reader_personalized"];
5
6
  export const REVIEW_BUNDLE_STRICTNESS = ["warn", "fail"];
@@ -124,12 +125,14 @@ export function buildReviewBundlePlan(dbHandle, {
124
125
  profile,
125
126
  part,
126
127
  chapter,
128
+ chapters,
127
129
  tag,
128
130
  scene_ids,
129
131
  strictness = "warn",
130
132
  include_scene_ids = true,
131
133
  include_metadata_sidebar = false,
132
134
  include_paragraph_anchors = false,
135
+ beta_accountability,
133
136
  bundle_name,
134
137
  recipient_name,
135
138
  format = "pdf",
@@ -148,10 +151,46 @@ export function buildReviewBundlePlan(dbHandle, {
148
151
  }
149
152
  );
150
153
  }
154
+ if (Array.isArray(chapters) && chapters.length > MAX_CHAPTER_FILTER_PARAMS) {
155
+ throw new ReviewBundlePlanError(
156
+ "CHAPTERS_FILTER_TOO_LARGE",
157
+ `chapters supports at most ${MAX_CHAPTER_FILTER_PARAMS} entries per request.`,
158
+ {
159
+ max_chapters: MAX_CHAPTER_FILTER_PARAMS,
160
+ received_chapters: chapters.length,
161
+ }
162
+ );
163
+ }
151
164
 
152
165
  assertProfile(profile);
153
166
  assertStrictness(strictness);
154
167
  assertFormat(format);
168
+ if (chapter !== undefined && chapters !== undefined) {
169
+ throw new ReviewBundlePlanError(
170
+ "INVALID_CHAPTER_FILTER",
171
+ "Use either chapter or chapters, not both.",
172
+ { chapter, chapters }
173
+ );
174
+ }
175
+ let normalizedChapters;
176
+ if (chapters !== undefined) {
177
+ if (!Array.isArray(chapters) || chapters.length === 0) {
178
+ throw new ReviewBundlePlanError(
179
+ "INVALID_CHAPTER_FILTER",
180
+ "chapters must be a non-empty array of integers when provided.",
181
+ { chapters }
182
+ );
183
+ }
184
+ if (!chapters.every(value => Number.isInteger(value))) {
185
+ throw new ReviewBundlePlanError(
186
+ "INVALID_CHAPTER_FILTER",
187
+ "chapters must contain only integer chapter numbers.",
188
+ { chapters }
189
+ );
190
+ }
191
+ // Normalize to a stable set to avoid plan drift from duplicated or reordered values.
192
+ normalizedChapters = Array.from(new Set(chapters)).sort((a, b) => a - b);
193
+ }
155
194
 
156
195
  const projectRow = dbHandle.prepare(`SELECT project_id FROM projects WHERE project_id = ?`).get(project_id);
157
196
  if (!projectRow) {
@@ -181,6 +220,11 @@ export function buildReviewBundlePlan(dbHandle, {
181
220
  conditions.push("s.chapter = ?");
182
221
  conditionParams.push(chapter);
183
222
  }
223
+ if (Array.isArray(normalizedChapters) && normalizedChapters.length > 0) {
224
+ const placeholders = normalizedChapters.map(() => "?").join(",");
225
+ conditions.push(`s.chapter IN (${placeholders})`);
226
+ conditionParams.push(...normalizedChapters);
227
+ }
184
228
 
185
229
  let query = `
186
230
  SELECT DISTINCT
@@ -213,6 +257,7 @@ export function buildReviewBundlePlan(dbHandle, {
213
257
  filters: {
214
258
  ...(part !== undefined ? { part } : {}),
215
259
  ...(chapter !== undefined ? { chapter } : {}),
260
+ ...(Array.isArray(normalizedChapters) ? { chapters: normalizedChapters } : {}),
216
261
  ...(tag ? { tag } : {}),
217
262
  ...(Array.isArray(scene_ids) ? { scene_ids } : {}),
218
263
  },
@@ -290,12 +335,23 @@ export function buildReviewBundlePlan(dbHandle, {
290
335
  : undefined;
291
336
 
292
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;
293
341
  const appliedFilters = {
294
342
  ...(part !== undefined ? { part } : {}),
295
343
  ...(chapter !== undefined ? { chapter } : {}),
344
+ ...(Array.isArray(normalizedChapters) ? { chapters: normalizedChapters } : {}),
296
345
  ...(tag ? { tag } : {}),
297
- ...(Array.isArray(scene_ids) ? { scene_ids } : {}),
346
+ ...(Array.isArray(normalizedSceneIds) ? { scene_ids: normalizedSceneIds } : {}),
298
347
  };
348
+ const resolvedBetaAccountability = profile === "beta_reader_personalized"
349
+ ? Boolean(beta_accountability ?? true)
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);
299
355
 
300
356
  return {
301
357
  ok: true,
@@ -304,9 +360,10 @@ export function buildReviewBundlePlan(dbHandle, {
304
360
  project_id,
305
361
  filters: appliedFilters,
306
362
  options: {
307
- include_scene_ids: Boolean(include_scene_ids),
308
- include_metadata_sidebar: Boolean(include_metadata_sidebar),
309
- include_paragraph_anchors: Boolean(include_paragraph_anchors),
363
+ include_scene_ids: resolvedIncludeSceneIds,
364
+ include_metadata_sidebar: resolvedIncludeMetadataSidebar,
365
+ include_paragraph_anchors: resolvedIncludeParagraphAnchors,
366
+ beta_accountability: resolvedBetaAccountability,
310
367
  ...(resolvedRecipientName ? { recipient_name: resolvedRecipientName } : {}),
311
368
  },
312
369
  },
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import crypto from "node:crypto";
3
4
  import matter from "gray-matter";
4
5
  import PDFDocument from "pdfkit";
5
6
  import { ReviewBundlePlanError, normalizeRecipientDisplayName } from "./review-bundles-planner.js";
@@ -296,11 +297,65 @@ function renderSceneBlock(scene, options) {
296
297
  return parts.join("\n\n");
297
298
  }
298
299
 
300
+ function normalizeFingerprintFilters(filters) {
301
+ const normalized = { ...(filters ?? {}) };
302
+ if (Array.isArray(normalized.scene_ids)) {
303
+ normalized.scene_ids = [...new Set(normalized.scene_ids.map(sceneId => String(sceneId)))].sort();
304
+ }
305
+ return normalized;
306
+ }
307
+
308
+ function stableSerializeForFingerprint(value) {
309
+ if (Array.isArray(value)) {
310
+ return value.map(item => stableSerializeForFingerprint(item));
311
+ }
312
+ if (value && typeof value === "object") {
313
+ return Object.keys(value)
314
+ .sort()
315
+ .reduce((result, key) => {
316
+ result[key] = stableSerializeForFingerprint(value[key]);
317
+ return result;
318
+ }, {});
319
+ }
320
+ return value;
321
+ }
322
+
323
+ function buildFingerprintSeed(plan, generatedAt, recipientDisplayName) {
324
+ const base = {
325
+ project_id: plan.resolved_scope?.project_id ?? "",
326
+ profile: plan.profile ?? "",
327
+ recipient_name: recipientDisplayName ?? "",
328
+ filters: normalizeFingerprintFilters(plan.resolved_scope?.filters),
329
+ scene_ids: (plan.ordering ?? []).map(row => row.scene_id),
330
+ generated_at: generatedAt ?? "",
331
+ };
332
+ return JSON.stringify(stableSerializeForFingerprint(base));
333
+ }
334
+
335
+ function buildFingerprintSeedHash(seed) {
336
+ return crypto.createHash("sha256").update(String(seed)).digest("hex");
337
+ }
338
+
339
+ function buildPageFingerprintToken({ seedHash, pageNumber }) {
340
+ const digest = crypto
341
+ .createHash("sha256")
342
+ .update(`${seedHash}|page:${pageNumber}`)
343
+ .digest("hex")
344
+ .slice(0, 12)
345
+ .toUpperCase();
346
+ return `BR-${digest}-P${String(pageNumber).padStart(3, "0")}`;
347
+ }
348
+
349
+ function sanitizeFooterRecipientDisplayName(recipientDisplayName) {
350
+ return String(recipientDisplayName ?? "").replaceAll("|", "/");
351
+ }
352
+
299
353
  export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
300
354
  const profile = plan.profile;
301
- const includeSceneIds = Boolean(plan.resolved_scope?.options?.include_scene_ids);
302
- const includeMetadataSidebar = Boolean(plan.resolved_scope?.options?.include_metadata_sidebar);
303
- 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);
304
359
  // Prefer explicitly threaded syncDir; fall back to env.
305
360
  // No further fallback: if syncDir is null, resolveSceneFilePath returns null
306
361
  // and SCENE_PROSE_READ_FAILED is thrown, making misconfiguration explicit.
@@ -315,12 +370,13 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
315
370
  const headerLines = [
316
371
  `# Review Bundle: ${escapeMarkdown(plan.resolved_scope.project_id)}`,
317
372
  "",
318
- `- Profile: ${profile}`,
373
+ ...(profile !== "beta_reader_personalized" ? [`- Profile: ${profile}`] : []),
319
374
  ...(profile === "beta_reader_personalized"
320
375
  ? [`- Recipient: ${escapeMarkdown(recipientDisplayName)}`]
321
376
  : []),
322
- `- Generated at: ${generatedAt ?? new Date().toISOString()}`,
323
- `- 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
+ : []),
324
380
  ];
325
381
  sections.push(headerLines.join("\n"));
326
382
 
@@ -365,24 +421,89 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
365
421
  }
366
422
 
367
423
  export function renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
424
+ return renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt, syncDir: syncDirOpt })
425
+ .then(result => result.pdf_buffer);
426
+ }
427
+
428
+ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
368
429
  const profile = plan.profile;
369
- 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);
370
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";
371
442
 
372
443
  const sceneIds = plan.ordering.map(row => row.scene_id);
373
444
  const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
374
445
  const recipientName = plan.resolved_scope?.options?.recipient_name;
375
446
  const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
376
-
447
+ const footerRecipientDisplayName = sanitizeFooterRecipientDisplayName(recipientDisplayName);
448
+ const betaAccountabilityEnabled = profile === "beta_reader_personalized"
449
+ && Boolean(plan.resolved_scope?.options?.beta_accountability);
450
+ const effectiveGeneratedAt = generatedAt ?? new Date().toISOString();
451
+ const fingerprintSeed = betaAccountabilityEnabled
452
+ ? buildFingerprintSeed(plan, effectiveGeneratedAt, recipientDisplayName)
453
+ : null;
454
+ const fingerprintSeedHash = fingerprintSeed ? buildFingerprintSeedHash(fingerprintSeed) : null;
455
+ const pageTokens = [];
456
+ let pageNumber = 0;
457
+
458
+ const pdfOptions = profile === "beta_reader_personalized"
459
+ ? {
460
+ size: [432, 648], // 6x9in in PDF points
461
+ // Extra bottom margin reserves clear space above the accountability footer.
462
+ margins: { top: 64, right: 58, bottom: 96, left: 58 },
463
+ autoFirstPage: false,
464
+ }
465
+ : {
466
+ size: "Letter",
467
+ margin: 50,
468
+ autoFirstPage: false,
469
+ };
377
470
  const doc = new PDFDocument({
378
- size: "Letter",
379
- margin: 50,
471
+ ...pdfOptions,
380
472
  });
381
473
 
474
+ const drawAccountabilityFooter = () => {
475
+ if (!betaAccountabilityEnabled || !fingerprintSeedHash) return;
476
+ const previousX = doc.x;
477
+ const previousY = doc.y;
478
+ pageNumber += 1;
479
+ const token = buildPageFingerprintToken({
480
+ seedHash: fingerprintSeedHash,
481
+ pageNumber,
482
+ });
483
+ pageTokens.push({ page: pageNumber, token });
484
+ const footerY = doc.page.height - 42;
485
+ const footerText = `For: ${footerRecipientDisplayName} | Fingerprint: ${token}`;
486
+ const pageNumberText = String(pageNumber);
487
+ doc.save();
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 });
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;
500
+ };
501
+ doc.on("pageAdded", drawAccountabilityFooter);
502
+
382
503
  // Register listeners before any content is written so render-time errors
383
504
  // always reject the returned Promise.
384
- const chunks = [];
385
505
  return new Promise((resolve, reject) => {
506
+ const chunks = [];
386
507
  let settled = false;
387
508
  const fail = (err) => {
388
509
  if (settled) return;
@@ -395,26 +516,39 @@ export function renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir: sy
395
516
  doc.on("end", () => {
396
517
  if (settled) return;
397
518
  settled = true;
398
- resolve(Buffer.concat(chunks));
519
+ resolve({
520
+ pdf_buffer: Buffer.concat(chunks),
521
+ fingerprint: betaAccountabilityEnabled
522
+ ? {
523
+ mode: "visible_footer",
524
+ recipient_display_name: recipientDisplayName,
525
+ page_tokens: pageTokens,
526
+ }
527
+ : null,
528
+ });
399
529
  });
400
530
 
401
531
  try {
402
- doc.fontSize(24).font("Helvetica-Bold").text(`Review Bundle: ${plan.resolved_scope.project_id}`, { align: "left" });
532
+ doc.addPage();
533
+ doc.fontSize(24).font(coverHeadingFont).text(`Review Bundle: ${plan.resolved_scope.project_id}`, { align: "left" });
403
534
  doc.moveDown(0.5);
404
- doc.fontSize(11).font("Helvetica");
405
- 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
+ }
406
539
  if (profile === "beta_reader_personalized") {
407
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" });
408
544
  }
409
- doc.text(`Generated: ${generatedAt ?? new Date().toISOString()}`, { align: "left" });
410
- doc.text(`Scenes: ${plan.summary.scene_count}`, { align: "left" });
411
545
  doc.moveDown();
412
546
 
413
547
  if (profile === "beta_reader_personalized") {
414
- doc.fontSize(12).font("Helvetica-Bold").text("Usage Notice", { align: "left" });
548
+ doc.fontSize(12).font("Times-Bold").text("Usage Notice", { align: "left" });
415
549
  doc.moveDown(0.3);
416
550
  const noticeWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
417
- doc.fontSize(10).font("Helvetica");
551
+ doc.fontSize(10).font("Times-Roman");
418
552
  doc.text("This beta-reader draft is intended for private review and feedback. Please do not redistribute without explicit author permission.", {
419
553
  align: "left",
420
554
  width: noticeWidth,
@@ -423,22 +557,29 @@ export function renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir: sy
423
557
  }
424
558
 
425
559
  for (const scene of rows) {
426
- 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);
427
566
  let heading = scene.title || scene.scene_id;
428
567
  if (includeSceneIds) {
429
568
  heading += ` [${scene.scene_id}]`;
430
569
  }
431
- doc.text(heading, { align: "left" });
432
- doc.moveDown(0.2);
570
+ doc.text(heading, { align: isBetaProfile ? "center" : "left" });
571
+ doc.moveDown(isBetaProfile ? 1.6 : 0.2);
433
572
 
434
573
  const metaParts = [];
435
- if (scene.pov) metaParts.push(`POV: ${scene.pov}`);
436
- 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
+ }
437
578
  if (metaParts.length > 0) {
438
579
  const metaWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
439
- doc.fontSize(9).font("Helvetica-Oblique");
580
+ doc.fontSize(9).font(metaFont);
440
581
  doc.text(metaParts.join(" • "), { align: "left", width: metaWidth });
441
- doc.font("Helvetica");
582
+ doc.font(bodyFont);
442
583
  doc.moveDown(0.2);
443
584
  }
444
585
 
@@ -466,11 +607,11 @@ export function renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir: sy
466
607
  prose = resolved;
467
608
 
468
609
  const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
469
- doc.fontSize(10).font("Helvetica");
610
+ doc.fontSize(proseFontSize).font(bodyFont);
470
611
  doc.text(prose, {
471
612
  align: "left",
472
613
  width: textWidth,
473
- lineGap: 3,
614
+ lineGap: proseLineGap,
474
615
  });
475
616
  }
476
617
 
@@ -495,4 +636,10 @@ export function renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir: sy
495
636
  });
496
637
  }
497
638
 
498
- export { renderBetaNoticeMarkdown, renderBetaFeedbackFormMarkdown };
639
+ export {
640
+ renderBetaNoticeMarkdown,
641
+ renderBetaFeedbackFormMarkdown,
642
+ buildPageFingerprintToken,
643
+ buildFingerprintSeed,
644
+ buildFingerprintSeedHash,
645
+ };
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { ReviewBundlePlanError } from "./review-bundles-planner.js";
4
- import { renderReviewBundleMarkdown, renderReviewBundlePdf, renderBetaNoticeMarkdown, renderBetaFeedbackFormMarkdown } from "./review-bundles-renderer.js";
4
+ import { renderReviewBundleMarkdown, renderReviewBundlePdfWithMetadata, renderBetaNoticeMarkdown, renderBetaFeedbackFormMarkdown } from "./review-bundles-renderer.js";
5
5
 
6
6
  function resolveOutputFilePath(outputDir, fileName) {
7
7
  const normalizedOutputDir = path.resolve(outputDir);
@@ -82,8 +82,11 @@ export async function createReviewBundleArtifacts(dbHandle, {
82
82
  const markdown = markdownPath ? renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir }) : null;
83
83
 
84
84
  let pdfBuffer = null;
85
+ let fingerprintMetadata = null;
85
86
  if (pdfPath) {
86
- pdfBuffer = await renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir });
87
+ const pdfResult = await renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt, syncDir });
88
+ pdfBuffer = pdfResult.pdf_buffer;
89
+ fingerprintMetadata = pdfResult.fingerprint;
87
90
  }
88
91
 
89
92
  const recipientName = plan.resolved_scope?.options?.recipient_name;
@@ -108,6 +111,7 @@ export async function createReviewBundleArtifacts(dbHandle, {
108
111
  warnings: plan.warnings,
109
112
  resolved_scope: plan.resolved_scope,
110
113
  scene_ids: plan.ordering.map(row => row.scene_id),
114
+ ...(fingerprintMetadata ? { fingerprint: fingerprintMetadata } : {}),
111
115
  };
112
116
 
113
117
  for (const outputPath of [markdownPath, pdfPath, manifestPath, noticePath, feedbackPath].filter(Boolean)) {
@@ -9,6 +9,10 @@ export {
9
9
  export {
10
10
  renderReviewBundleMarkdown,
11
11
  renderReviewBundlePdf,
12
+ renderReviewBundlePdfWithMetadata,
13
+ buildPageFingerprintToken,
14
+ buildFingerprintSeed,
15
+ buildFingerprintSeedHash,
12
16
  } from "./review-bundles-renderer.js";
13
17
 
14
18
  export { createReviewBundleArtifacts } from "./review-bundles-writer.js";
@@ -28,6 +28,7 @@ export function registerReviewBundleTools(s, {
28
28
  profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
29
29
  part: z.number().int().optional().describe("Optional part filter."),
30
30
  chapter: z.number().int().optional().describe("Optional chapter filter."),
31
+ chapters: z.array(z.number().int()).min(1).optional().describe("Optional chapter-set filter. Use this for one/few specific chapters. Do not combine with chapter."),
31
32
  tag: z.string().optional().describe("Optional tag filter (exact match)."),
32
33
  scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
33
34
  strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
@@ -35,6 +36,7 @@ export function registerReviewBundleTools(s, {
35
36
  include_metadata_sidebar: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
36
37
  include_paragraph_anchors: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
37
38
  recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
39
+ beta_accountability: z.boolean().optional().describe("Enable accountability footer + fingerprint metadata for beta_reader_personalized output (default true for beta profile)."),
38
40
  bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in planned outputs)."),
39
41
  format: z.enum(["pdf", "markdown", "both"]).optional().describe("Planned output format: pdf (default), markdown, or both. Affects planned_outputs filenames only; preview_review_bundle does not render artifacts."),
40
42
  },
@@ -43,6 +45,7 @@ export function registerReviewBundleTools(s, {
43
45
  profile,
44
46
  part,
45
47
  chapter,
48
+ chapters,
46
49
  tag,
47
50
  scene_ids,
48
51
  strictness = "warn",
@@ -50,6 +53,7 @@ export function registerReviewBundleTools(s, {
50
53
  include_metadata_sidebar = false,
51
54
  include_paragraph_anchors = false,
52
55
  recipient_name,
56
+ beta_accountability,
53
57
  bundle_name,
54
58
  format = "pdf",
55
59
  }) => {
@@ -64,6 +68,7 @@ export function registerReviewBundleTools(s, {
64
68
  profile,
65
69
  part,
66
70
  chapter,
71
+ chapters,
67
72
  tag,
68
73
  scene_ids,
69
74
  strictness,
@@ -71,6 +76,7 @@ export function registerReviewBundleTools(s, {
71
76
  include_metadata_sidebar,
72
77
  include_paragraph_anchors,
73
78
  recipient_name,
79
+ beta_accountability,
74
80
  bundle_name,
75
81
  format,
76
82
  });
@@ -110,6 +116,7 @@ export function registerReviewBundleTools(s, {
110
116
  output_dir: z.string().describe("Directory path to write bundle artifacts into."),
111
117
  part: z.number().int().optional().describe("Optional part filter."),
112
118
  chapter: z.number().int().optional().describe("Optional chapter filter."),
119
+ chapters: z.array(z.number().int()).min(1).optional().describe("Optional chapter-set filter. Use this for one/few specific chapters. Do not combine with chapter."),
113
120
  tag: z.string().optional().describe("Optional tag filter (exact match)."),
114
121
  scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
115
122
  strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
@@ -117,6 +124,7 @@ export function registerReviewBundleTools(s, {
117
124
  include_metadata_sidebar: z.boolean().optional().describe("Include metadata sidebar in markdown output (default false). Markdown only — no effect on PDF."),
118
125
  include_paragraph_anchors: z.boolean().optional().describe("Include paragraph anchors in markdown output (default false). Markdown only — no effect on PDF."),
119
126
  recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
127
+ beta_accountability: z.boolean().optional().describe("Enable accountability footer + fingerprint metadata for beta_reader_personalized output (default true for beta profile)."),
120
128
  bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in filenames)."),
121
129
  source_commit: z.string().optional().describe("Optional explicit source commit for provenance. Defaults to current HEAD when available."),
122
130
  format: z.enum(["pdf", "markdown", "both"]).optional().describe("Output format: pdf (default), markdown, or both."),
@@ -127,6 +135,7 @@ export function registerReviewBundleTools(s, {
127
135
  output_dir,
128
136
  part,
129
137
  chapter,
138
+ chapters,
130
139
  tag,
131
140
  scene_ids,
132
141
  strictness = "warn",
@@ -134,6 +143,7 @@ export function registerReviewBundleTools(s, {
134
143
  include_metadata_sidebar = false,
135
144
  include_paragraph_anchors = false,
136
145
  recipient_name,
146
+ beta_accountability,
137
147
  bundle_name,
138
148
  source_commit,
139
149
  format = "pdf",
@@ -162,6 +172,7 @@ export function registerReviewBundleTools(s, {
162
172
  profile,
163
173
  part,
164
174
  chapter,
175
+ chapters,
165
176
  tag,
166
177
  scene_ids,
167
178
  strictness,
@@ -169,6 +180,7 @@ export function registerReviewBundleTools(s, {
169
180
  include_metadata_sidebar,
170
181
  include_paragraph_anchors,
171
182
  recipient_name,
183
+ beta_accountability,
172
184
  bundle_name,
173
185
  format,
174
186
  });