@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 +14 -0
- package/package.json +1 -1
- package/src/review-bundles/review-bundles-planner.js +61 -4
- package/src/review-bundles/review-bundles-renderer.js +176 -29
- package/src/review-bundles/review-bundles-writer.js +6 -2
- package/src/review-bundles/review-bundles.js +4 -0
- package/src/tools/review-bundles.js +12 -0
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,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(
|
|
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:
|
|
308
|
-
include_metadata_sidebar:
|
|
309
|
-
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
|
|
302
|
-
const
|
|
303
|
-
const
|
|
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
|
-
|
|
323
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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(
|
|
405
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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 (
|
|
436
|
-
|
|
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(
|
|
580
|
+
doc.fontSize(9).font(metaFont);
|
|
440
581
|
doc.text(metaParts.join(" • "), { align: "left", width: metaWidth });
|
|
441
|
-
doc.font(
|
|
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(
|
|
610
|
+
doc.fontSize(proseFontSize).font(bodyFont);
|
|
470
611
|
doc.text(prose, {
|
|
471
612
|
align: "left",
|
|
472
613
|
width: textWidth,
|
|
473
|
-
lineGap:
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
});
|