diff --git a/collab-cover-letter-export-guard/README.md b/collab-cover-letter-export-guard/README.md new file mode 100644 index 00000000..cba46bea --- /dev/null +++ b/collab-cover-letter-export-guard/README.md @@ -0,0 +1,33 @@ +# Collaborative Cover-Letter Export Guard + +Self-contained reviewer artifact for SCIBASE issue #12, focused on the last step before a collaborative manuscript export includes its journal cover letter. + +The guard evaluates synthetic submission packets for: + +- stale manuscript and cover-letter hashes after final collaborative edits +- missing or stale corresponding-author approval +- coauthor approval drift after the final manuscript version +- novelty, ethics, data availability, and conflict statement mismatches +- private reviewer or collaborator notes leaking into the submission-facing cover letter + +It emits deterministic `RELEASE`, `REVIEW`, or `HOLD` decisions with JSON, Markdown, SVG, and MP4 artifacts. It uses no credentials, no private manuscripts, no external APIs, and no live editor service. + +## Requirement Mapping + +| Issue #12 capability | Covered here | +| --- | --- | +| Inline comments, suggestions, and change tracking | Blocks cover-letter export when private or unresolved collaborator notes are included | +| Version history and autosave | Requires cover-letter and approvals to match the final manuscript hash | +| Integrated task workflow | Produces remediation actions for missing approvals and claim drift | +| Scientific formatting and publication workflow | Validates the journal-facing correspondence packet before export | + +## Local Validation + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +Generated artifacts are written to `reports/`. diff --git a/collab-cover-letter-export-guard/demo.js b/collab-cover-letter-export-guard/demo.js new file mode 100644 index 00000000..201dd25b --- /dev/null +++ b/collab-cover-letter-export-guard/demo.js @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; +import { cleanSubmissionPacket, riskySubmissionPacket } from "./src/samplePackets.js"; +import { evaluateCoverLetterExportPacket, renderMarkdownReport } from "./src/guard.js"; + +const reportsDir = path.join(process.cwd(), "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const cleanResult = evaluateCoverLetterExportPacket(cleanSubmissionPacket); +const riskyResult = evaluateCoverLetterExportPacket(riskySubmissionPacket); + +fs.writeFileSync(path.join(reportsDir, "clean-review.json"), `${JSON.stringify(cleanResult, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-review.json"), `${JSON.stringify(riskyResult, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-review.md"), renderMarkdownReport(riskySubmissionPacket, riskyResult)); + +const svg = ` + + Collaborative Cover-Letter Export Guard + Decision: ${riskyResult.decision} + Findings: ${riskyResult.findingCount} | High: ${riskyResult.highCount} | Medium: ${riskyResult.mediumCount} + Checks stale hashes, approval drift, claim mismatches, and private-note leakage. + Audit digest: ${riskyResult.auditDigest} + +`; +fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg); + +console.log(`Clean decision: ${cleanResult.decision}`); +console.log(`Risky decision: ${riskyResult.decision}`); +console.log(`Wrote reports to ${reportsDir}`); diff --git a/collab-cover-letter-export-guard/make-demo-video.js b/collab-cover-letter-export-guard/make-demo-video.js new file mode 100644 index 00000000..00f8982e --- /dev/null +++ b/collab-cover-letter-export-guard/make-demo-video.js @@ -0,0 +1,50 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { riskySubmissionPacket } from "./src/samplePackets.js"; +import { evaluateCoverLetterExportPacket } from "./src/guard.js"; + +const reportsDir = path.join(process.cwd(), "reports"); +const demoMp4 = path.join(reportsDir, "demo.mp4"); +const resultPacket = evaluateCoverLetterExportPacket(riskySubmissionPacket); + +function escapeDrawtext(text) { + return text.replaceAll("\\", "\\\\").replaceAll(":", "\\:").replaceAll("'", "\\'"); +} + +const font = "C\\:/Windows/Fonts/arial.ttf"; +const lines = [ + "Collaborative Cover-Letter Export Guard", + `Decision ${resultPacket.decision} | Findings ${resultPacket.findingCount}`, + "Blocks stale hashes, approval drift, claim mismatches, and private-note leakage.", + `Audit digest ${resultPacket.auditDigest}`, +]; +const drawText = lines + .map( + (line, index) => + `drawtext=fontfile='${font}':text='${escapeDrawtext(line)}':x=48:y=${64 + index * 72}:fontsize=${index === 0 ? 34 : 24}:fontcolor=${index === 1 ? "0xffdddd" : "white"}`, + ) + .join(","); + +const result = spawnSync( + "ffmpeg", + [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x101827:s=960x540:r=12", + "-t", + "4", + "-vf", + `${drawText},format=yuv420p`, + "-an", + demoMp4, + ], + { encoding: "utf8" }, +); + +if (result.status !== 0) { + throw new Error(result.stderr || "ffmpeg failed to render demo.mp4"); +} + +console.log(`Wrote ${path.relative(process.cwd(), demoMp4)}`); diff --git a/collab-cover-letter-export-guard/package.json b/collab-cover-letter-export-guard/package.json new file mode 100644 index 00000000..e49510d4 --- /dev/null +++ b/collab-cover-letter-export-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "collab-cover-letter-export-guard", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Dependency-free guard for collaborative journal cover-letter export readiness.", + "scripts": { + "check": "node --check src/guard.js && node --check src/samplePackets.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node make-demo-video.js" + } +} diff --git a/collab-cover-letter-export-guard/reports/clean-review.json b/collab-cover-letter-export-guard/reports/clean-review.json new file mode 100644 index 00000000..6e0d85d6 --- /dev/null +++ b/collab-cover-letter-export-guard/reports/clean-review.json @@ -0,0 +1,10 @@ +{ + "packetId": "submission-ready-cover-letter", + "decision": "RELEASE", + "findingCount": 0, + "highCount": 0, + "mediumCount": 0, + "findings": [], + "exportReady": true, + "auditDigest": "b987f52224b801a1" +} diff --git a/collab-cover-letter-export-guard/reports/demo.mp4 b/collab-cover-letter-export-guard/reports/demo.mp4 new file mode 100644 index 00000000..6d1275ba Binary files /dev/null and b/collab-cover-letter-export-guard/reports/demo.mp4 differ diff --git a/collab-cover-letter-export-guard/reports/risky-review.json b/collab-cover-letter-export-guard/reports/risky-review.json new file mode 100644 index 00000000..92f7bd05 --- /dev/null +++ b/collab-cover-letter-export-guard/reports/risky-review.json @@ -0,0 +1,59 @@ +{ + "packetId": "submission-blocked-cover-letter", + "decision": "HOLD", + "findingCount": 8, + "highCount": 5, + "mediumCount": 3, + "findings": [ + { + "severity": "high", + "code": "STALE_COVER_LETTER_HASH", + "message": "Cover letter references ms-v10-21bd, but final manuscript is ms-v11-aa30.", + "remediation": "Regenerate the cover-letter packet from the final manuscript version." + }, + { + "severity": "high", + "code": "MISSING_CORRESPONDING_AUTHOR_APPROVAL", + "message": "Corresponding author approval is missing or tied to an older manuscript hash.", + "remediation": "Collect a fresh corresponding-author approval after the final edit." + }, + { + "severity": "medium", + "code": "COAUTHOR_APPROVAL_DRIFT", + "message": "Missing fresh cover-letter approval from author-2, author-3.", + "remediation": "Route the final cover-letter packet to every coauthor who is listed on the manuscript." + }, + { + "severity": "medium", + "code": "CLAIM_MISMATCH_NOVELTY", + "message": "Cover-letter novelty statement diverges from the manuscript claim.", + "remediation": "Align cover-letter statements with the final manuscript disclosure text before export." + }, + { + "severity": "high", + "code": "CLAIM_MISMATCH_ETHICS", + "message": "Cover-letter ethics statement diverges from the manuscript claim.", + "remediation": "Align cover-letter statements with the final manuscript disclosure text before export." + }, + { + "severity": "medium", + "code": "CLAIM_MISMATCH_DATAAVAILABILITY", + "message": "Cover-letter dataAvailability statement diverges from the manuscript claim.", + "remediation": "Align cover-letter statements with the final manuscript disclosure text before export." + }, + { + "severity": "high", + "code": "CLAIM_MISMATCH_CONFLICTS", + "message": "Cover-letter conflicts statement diverges from the manuscript claim.", + "remediation": "Align cover-letter statements with the final manuscript disclosure text before export." + }, + { + "severity": "high", + "code": "PRIVATE_COMMENT_LEAK", + "message": "Cover-letter export includes private reviewer or collaborator notes.", + "remediation": "Redact private comments and rebuild the editor-facing correspondence packet." + } + ], + "exportReady": false, + "auditDigest": "fa958c5cf99c0caa" +} diff --git a/collab-cover-letter-export-guard/reports/risky-review.md b/collab-cover-letter-export-guard/reports/risky-review.md new file mode 100644 index 00000000..0822060b --- /dev/null +++ b/collab-cover-letter-export-guard/reports/risky-review.md @@ -0,0 +1,25 @@ +# Cover Letter Export Guard Report + +- Packet: submission-blocked-cover-letter +- Manuscript: Model-assisted detection of assay drift in synthetic submissions +- Decision: HOLD +- Audit digest: fa958c5cf99c0caa + +## Findings + +- HIGH STALE_COVER_LETTER_HASH: Cover letter references ms-v10-21bd, but final manuscript is ms-v11-aa30. + Remediation: Regenerate the cover-letter packet from the final manuscript version. +- HIGH MISSING_CORRESPONDING_AUTHOR_APPROVAL: Corresponding author approval is missing or tied to an older manuscript hash. + Remediation: Collect a fresh corresponding-author approval after the final edit. +- MEDIUM COAUTHOR_APPROVAL_DRIFT: Missing fresh cover-letter approval from author-2, author-3. + Remediation: Route the final cover-letter packet to every coauthor who is listed on the manuscript. +- MEDIUM CLAIM_MISMATCH_NOVELTY: Cover-letter novelty statement diverges from the manuscript claim. + Remediation: Align cover-letter statements with the final manuscript disclosure text before export. +- HIGH CLAIM_MISMATCH_ETHICS: Cover-letter ethics statement diverges from the manuscript claim. + Remediation: Align cover-letter statements with the final manuscript disclosure text before export. +- MEDIUM CLAIM_MISMATCH_DATAAVAILABILITY: Cover-letter dataAvailability statement diverges from the manuscript claim. + Remediation: Align cover-letter statements with the final manuscript disclosure text before export. +- HIGH CLAIM_MISMATCH_CONFLICTS: Cover-letter conflicts statement diverges from the manuscript claim. + Remediation: Align cover-letter statements with the final manuscript disclosure text before export. +- HIGH PRIVATE_COMMENT_LEAK: Cover-letter export includes private reviewer or collaborator notes. + Remediation: Redact private comments and rebuild the editor-facing correspondence packet. diff --git a/collab-cover-letter-export-guard/reports/summary.svg b/collab-cover-letter-export-guard/reports/summary.svg new file mode 100644 index 00000000..4c71fe1d --- /dev/null +++ b/collab-cover-letter-export-guard/reports/summary.svg @@ -0,0 +1,8 @@ + + + Collaborative Cover-Letter Export Guard + Decision: HOLD + Findings: 8 | High: 5 | Medium: 3 + Checks stale hashes, approval drift, claim mismatches, and private-note leakage. + Audit digest: fa958c5cf99c0caa + diff --git a/collab-cover-letter-export-guard/src/guard.js b/collab-cover-letter-export-guard/src/guard.js new file mode 100644 index 00000000..ba8ccc2d --- /dev/null +++ b/collab-cover-letter-export-guard/src/guard.js @@ -0,0 +1,152 @@ +import crypto from "node:crypto"; + +const CLAIM_KEYS = ["novelty", "ethics", "dataAvailability", "conflicts"]; + +function normalizeText(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function textSimilarity(left, right) { + const leftTerms = new Set(normalizeText(left).split(" ").filter(Boolean)); + const rightTerms = new Set(normalizeText(right).split(" ").filter(Boolean)); + if (leftTerms.size === 0 && rightTerms.size === 0) return 1; + const overlap = [...leftTerms].filter((term) => rightTerms.has(term)).length; + return overlap / Math.max(leftTerms.size, rightTerms.size); +} + +function addFinding(findings, severity, code, message, remediation) { + findings.push({ severity, code, message, remediation }); +} + +function hasPrivateLeak(packet) { + const noteText = packet.coverLetter.editorNotes.join("\n").toLowerCase(); + return ( + packet.coverLetter.privateCommentExports.length > 0 || + /private reviewer|internal note|unresolved collaborator|do not disclose/.test(noteText) + ); +} + +function evaluateApprovalFreshness(packet, findings) { + const expectedHash = packet.manuscript.versionHash; + const cover = packet.coverLetter; + + if (cover.manuscriptHash !== expectedHash) { + addFinding( + findings, + "high", + "STALE_COVER_LETTER_HASH", + `Cover letter references ${cover.manuscriptHash}, but final manuscript is ${expectedHash}.`, + "Regenerate the cover-letter packet from the final manuscript version.", + ); + } + + if (!cover.correspondingAuthorApproval || cover.correspondingAuthorApproval.approvedHash !== expectedHash) { + addFinding( + findings, + "high", + "MISSING_CORRESPONDING_AUTHOR_APPROVAL", + "Corresponding author approval is missing or tied to an older manuscript hash.", + "Collect a fresh corresponding-author approval after the final edit.", + ); + } + + const requiredCoauthors = packet.collaborators + .filter((collaborator) => collaborator.role === "coauthor") + .map((collaborator) => collaborator.authorId); + const approvedCoauthors = new Set( + cover.coauthorApprovals + .filter((approval) => approval.approvedHash === expectedHash) + .map((approval) => approval.authorId), + ); + const missing = requiredCoauthors.filter((authorId) => !approvedCoauthors.has(authorId)); + if (missing.length > 0) { + addFinding( + findings, + "medium", + "COAUTHOR_APPROVAL_DRIFT", + `Missing fresh cover-letter approval from ${missing.join(", ")}.`, + "Route the final cover-letter packet to every coauthor who is listed on the manuscript.", + ); + } +} + +function evaluateStatementConsistency(packet, findings) { + for (const key of CLAIM_KEYS) { + const manuscriptValue = packet.manuscript.claims[key]; + const coverValue = packet.coverLetter.statements[key]; + const similarity = textSimilarity(manuscriptValue, coverValue); + if (similarity < 0.45) { + addFinding( + findings, + key === "conflicts" || key === "ethics" ? "high" : "medium", + `CLAIM_MISMATCH_${key.toUpperCase()}`, + `Cover-letter ${key} statement diverges from the manuscript claim.`, + "Align cover-letter statements with the final manuscript disclosure text before export.", + ); + } + } +} + +export function evaluateCoverLetterExportPacket(packet) { + const findings = []; + evaluateApprovalFreshness(packet, findings); + evaluateStatementConsistency(packet, findings); + + if (hasPrivateLeak(packet)) { + addFinding( + findings, + "high", + "PRIVATE_COMMENT_LEAK", + "Cover-letter export includes private reviewer or collaborator notes.", + "Redact private comments and rebuild the editor-facing correspondence packet.", + ); + } + + const highCount = findings.filter((finding) => finding.severity === "high").length; + const mediumCount = findings.filter((finding) => finding.severity === "medium").length; + const decision = highCount > 0 ? "HOLD" : mediumCount > 0 ? "REVIEW" : "RELEASE"; + const auditDigest = crypto + .createHash("sha256") + .update(JSON.stringify({ id: packet.id, decision, findings })) + .digest("hex") + .slice(0, 16); + + return { + packetId: packet.id, + decision, + findingCount: findings.length, + highCount, + mediumCount, + findings, + exportReady: decision === "RELEASE", + auditDigest, + }; +} + +export function renderMarkdownReport(packet, result) { + const lines = [ + `# Cover Letter Export Guard Report`, + "", + `- Packet: ${packet.id}`, + `- Manuscript: ${packet.manuscript.title}`, + `- Decision: ${result.decision}`, + `- Audit digest: ${result.auditDigest}`, + "", + "## Findings", + "", + ]; + + if (result.findings.length === 0) { + lines.push("- No blockers found. Cover-letter export is ready for journal submission."); + } else { + for (const finding of result.findings) { + lines.push(`- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`); + lines.push(` Remediation: ${finding.remediation}`); + } + } + + return `${lines.join("\n")}\n`; +} diff --git a/collab-cover-letter-export-guard/src/samplePackets.js b/collab-cover-letter-export-guard/src/samplePackets.js new file mode 100644 index 00000000..ff1b5c42 --- /dev/null +++ b/collab-cover-letter-export-guard/src/samplePackets.js @@ -0,0 +1,91 @@ +export const cleanSubmissionPacket = { + id: "submission-ready-cover-letter", + manuscript: { + versionHash: "ms-v9-6f2a", + title: "Longitudinal biomarker stability in synthetic cohort data", + claims: { + novelty: "First preregistered multi-site synthetic stability analysis in this cohort.", + ethics: "Synthetic-only data with no human participant identifiers.", + dataAvailability: "Synthetic data and analysis code are available in the linked repository.", + conflicts: "The authors declare no competing interests.", + }, + finalEditedAt: "2026-06-21T12:00:00Z", + }, + coverLetter: { + manuscriptHash: "ms-v9-6f2a", + editedAt: "2026-06-21T12:08:00Z", + correspondingAuthorApproval: { + authorId: "author-1", + approvedHash: "ms-v9-6f2a", + approvedAt: "2026-06-21T12:12:00Z", + }, + coauthorApprovals: [ + { authorId: "author-2", approvedHash: "ms-v9-6f2a", approvedAt: "2026-06-21T12:20:00Z" }, + { authorId: "author-3", approvedHash: "ms-v9-6f2a", approvedAt: "2026-06-21T12:21:00Z" }, + ], + statements: { + novelty: "First preregistered multi-site synthetic stability analysis in this cohort.", + ethics: "Synthetic-only data with no human participant identifiers.", + dataAvailability: "Synthetic data and analysis code are available in the linked repository.", + conflicts: "The authors declare no competing interests.", + }, + editorNotes: [ + "We believe the study fits the journal scope because it demonstrates reproducible synthetic cohort validation.", + ], + privateCommentExports: [], + }, + collaborators: [ + { authorId: "author-1", role: "corresponding-author" }, + { authorId: "author-2", role: "coauthor" }, + { authorId: "author-3", role: "coauthor" }, + ], +}; + +export const riskySubmissionPacket = { + id: "submission-blocked-cover-letter", + manuscript: { + versionHash: "ms-v11-aa30", + title: "Model-assisted detection of assay drift in synthetic submissions", + claims: { + novelty: "A synthetic benchmark for assay drift detection across three labs.", + ethics: "Synthetic samples only; no patient identifiers are included.", + dataAvailability: "Synthetic data will be released after editorial acceptance.", + conflicts: "Author B consults for LabScale Analytics.", + }, + finalEditedAt: "2026-06-22T18:40:00Z", + }, + coverLetter: { + manuscriptHash: "ms-v10-21bd", + editedAt: "2026-06-22T17:45:00Z", + correspondingAuthorApproval: { + authorId: "author-1", + approvedHash: "ms-v10-21bd", + approvedAt: "2026-06-22T17:50:00Z", + }, + coauthorApprovals: [ + { authorId: "author-2", approvedHash: "ms-v10-21bd", approvedAt: "2026-06-22T17:51:00Z" }, + ], + statements: { + novelty: "The first assay drift detector validated in live clinical data.", + ethics: "No ethics statement is required.", + dataAvailability: "All raw data are publicly available now.", + conflicts: "The authors declare no competing interests.", + }, + editorNotes: [ + "Private reviewer note: Reviewer 2 questioned whether the synthetic cohort is sufficient.", + "Please consider this despite the unresolved collaborator comment in section 4.", + ], + privateCommentExports: [ + { + commentId: "c-private-77", + section: "Results", + text: "Internal note: do not disclose LabScale consulting until legal confirms wording.", + }, + ], + }, + collaborators: [ + { authorId: "author-1", role: "corresponding-author" }, + { authorId: "author-2", role: "coauthor" }, + { authorId: "author-3", role: "coauthor" }, + ], +}; diff --git a/collab-cover-letter-export-guard/test.js b/collab-cover-letter-export-guard/test.js new file mode 100644 index 00000000..21b0d0f0 --- /dev/null +++ b/collab-cover-letter-export-guard/test.js @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { cleanSubmissionPacket, riskySubmissionPacket } from "./src/samplePackets.js"; +import { evaluateCoverLetterExportPacket, renderMarkdownReport } from "./src/guard.js"; + +const cleanResult = evaluateCoverLetterExportPacket(cleanSubmissionPacket); +assert.equal(cleanResult.decision, "RELEASE"); +assert.equal(cleanResult.findingCount, 0); +assert.equal(cleanResult.exportReady, true); + +const riskyResult = evaluateCoverLetterExportPacket(riskySubmissionPacket); +assert.equal(riskyResult.decision, "HOLD"); +assert.equal(riskyResult.exportReady, false); +assert.ok(riskyResult.findings.some((finding) => finding.code === "STALE_COVER_LETTER_HASH")); +assert.ok(riskyResult.findings.some((finding) => finding.code === "MISSING_CORRESPONDING_AUTHOR_APPROVAL")); +assert.ok(riskyResult.findings.some((finding) => finding.code === "PRIVATE_COMMENT_LEAK")); +assert.ok(riskyResult.findings.some((finding) => finding.code === "CLAIM_MISMATCH_CONFLICTS")); + +const report = renderMarkdownReport(riskySubmissionPacket, riskyResult); +assert.match(report, /Cover Letter Export Guard Report/); +assert.match(report, /HOLD/); +assert.match(report, /PRIVATE_COMMENT_LEAK/); + +console.log("4 cover-letter export guard tests passed");