diff --git a/biomethods-provenance-assistant/README.md b/biomethods-provenance-assistant/README.md
new file mode 100644
index 00000000..5727c8ff
--- /dev/null
+++ b/biomethods-provenance-assistant/README.md
@@ -0,0 +1,31 @@
+# Biomethods Provenance Assistant
+
+Self-contained AI-assisted research tools slice for issue #13. It gives peer reviewers a deterministic biology methods provenance packet before manuscript review.
+
+## What It Checks
+
+- Biospecimen source, organism, strain, sex, and age context.
+- Cell-line source, authentication method/date, and mycoplasma test evidence.
+- Critical reagent vendor, catalog, lot, and antibody RRID traceability.
+- Reagent batch conflicts across method sections that need author explanation.
+
+## Outputs
+
+- `reports/biomethods-provenance-packet.json`: structured reviewer decisions and findings.
+- `reports/reviewer-report.md`: readable reviewer report for each synthetic scenario.
+- `reports/summary.svg`: visual decision summary.
+- `reports/demo.mp4`: short demo artifact for Algora review.
+
+## Local Verification
+
+```bash
+node --test biomethods-provenance-assistant/test.js
+node biomethods-provenance-assistant/demo.js
+node --check biomethods-provenance-assistant/index.js
+node --check biomethods-provenance-assistant/sample-data.js
+node --check biomethods-provenance-assistant/demo.js
+node --check biomethods-provenance-assistant/test.js
+```
+
+The module is dependency-free, uses synthetic data only, and makes no network calls.
+Set `FFMPEG_PATH` to an ffmpeg binary before running `demo.js` if regenerating `reports/demo.mp4`.
diff --git a/biomethods-provenance-assistant/demo.js b/biomethods-provenance-assistant/demo.js
new file mode 100644
index 00000000..539be06d
--- /dev/null
+++ b/biomethods-provenance-assistant/demo.js
@@ -0,0 +1,131 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const {spawnSync} = require('node:child_process');
+
+const {
+ evaluateBiomethodsProvenance,
+ buildReviewerReport,
+} = require('./index');
+const {scenarios} = require('./sample-data');
+
+const reportsDir = path.join(__dirname, 'reports');
+const framesDir = path.join(reportsDir, 'frames');
+fs.mkdirSync(reportsDir, {recursive: true});
+
+const evaluations = scenarios.map((scenario) => ({
+ scenario: scenario.name,
+ ...evaluateBiomethodsProvenance(scenario),
+}));
+
+const packetJson = JSON.stringify(evaluations, null, 2);
+const reviewerReport = evaluations.map(buildReviewerReport).join('\n---\n');
+const decisionCounts = evaluations.reduce((counts, item) => {
+ counts[item.decision] = (counts[item.decision] || 0) + 1;
+ return counts;
+}, {});
+const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0);
+
+const svg = `
+`;
+
+fs.writeFileSync(path.join(reportsDir, 'biomethods-provenance-packet.json'), `${packetJson}\n`);
+fs.writeFileSync(path.join(reportsDir, 'reviewer-report.md'), reviewerReport);
+fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg);
+
+function fillRect(buffer, width, x0, y0, rectWidth, rectHeight, color) {
+ const [r, g, b] = color;
+ const x1 = Math.min(width, x0 + rectWidth);
+ const y1 = Math.min(Math.floor(buffer.length / (width * 3)), y0 + rectHeight);
+ for (let y = Math.max(0, y0); y < y1; y += 1) {
+ for (let x = Math.max(0, x0); x < x1; x += 1) {
+ const offset = (y * width + x) * 3;
+ buffer[offset] = r;
+ buffer[offset + 1] = g;
+ buffer[offset + 2] = b;
+ }
+ }
+}
+
+function writePpmFrame(filePath, frameIndex, frameCount) {
+ const width = 640;
+ const height = 360;
+ const buffer = Buffer.alloc(width * height * 3, 18);
+ for (let i = 0; i < width * height; i += 1) {
+ buffer[i * 3] = 16;
+ buffer[i * 3 + 1] = 24;
+ buffer[i * 3 + 2] = 32;
+ }
+ const progress = frameIndex / Math.max(1, frameCount - 1);
+ fillRect(buffer, width, 42, 54, 556, 42, [248, 250, 252]);
+ fillRect(buffer, width, 42, 118, 160, 108, [127, 29, 29]);
+ fillRect(buffer, width, 240, 118, 160, 108, [146, 64, 14]);
+ fillRect(buffer, width, 438, 118, 160, 108, [6, 95, 70]);
+ fillRect(buffer, width, 42, 258, Math.round(556 * progress), 26, [185, 230, 255]);
+ fillRect(buffer, width, 42 + Math.round(480 * progress), 300, 72, 28, [248, 250, 252]);
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`);
+ fs.writeFileSync(filePath, Buffer.concat([header, buffer]));
+}
+
+function createDemoVideo() {
+ const ffmpegPath = process.env.FFMPEG_PATH;
+ if (!ffmpegPath) {
+ console.log('FFMPEG_PATH not set; skipped MP4 generation.');
+ return;
+ }
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ fs.mkdirSync(framesDir, {recursive: true});
+ const frameCount = 72;
+ for (let index = 0; index < frameCount; index += 1) {
+ writePpmFrame(path.join(framesDir, `frame-${String(index).padStart(3, '0')}.ppm`), index, frameCount);
+ }
+
+ const output = path.join(reportsDir, 'demo.mp4');
+ const result = spawnSync(ffmpegPath, [
+ '-y',
+ '-framerate',
+ '24',
+ '-i',
+ path.join(framesDir, 'frame-%03d.ppm'),
+ '-c:v',
+ 'libx264',
+ '-pix_fmt',
+ 'yuv420p',
+ '-movflags',
+ '+faststart',
+ output,
+ ], {encoding: 'utf8'});
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ if (result.status !== 0) {
+ throw new Error(result.stderr || 'ffmpeg failed to generate demo.mp4');
+ }
+}
+
+createDemoVideo();
+
+console.log(`Wrote ${evaluations.length} biomethods evaluations to ${reportsDir}`);
+console.log(`Decision counts: ${JSON.stringify(decisionCounts)}`);
+console.log(`Reviewer findings: ${findings}`);
diff --git a/biomethods-provenance-assistant/index.js b/biomethods-provenance-assistant/index.js
new file mode 100644
index 00000000..4a1d5298
--- /dev/null
+++ b/biomethods-provenance-assistant/index.js
@@ -0,0 +1,341 @@
+function list(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function hasText(value) {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+function fullDaysBetween(older, newer) {
+ const olderTime = Date.parse(older);
+ const newerTime = Date.parse(newer);
+ if (!Number.isFinite(olderTime) || !Number.isFinite(newerTime)) {
+ return Infinity;
+ }
+ return Math.floor((newerTime - olderTime) / 86400000);
+}
+
+function reviewerAction(type, target, reason) {
+ return {type, target, reason};
+}
+
+function addFinding(findings, requiredActions, finding, action) {
+ findings.push(finding);
+ if (action) {
+ requiredActions.push(action);
+ }
+}
+
+function reagentKey(reagent) {
+ return [
+ reagent.vendor || 'unknown-vendor',
+ reagent.catalogNumber || reagent.name || reagent.id || 'unknown-reagent',
+ ].join('::').toLowerCase();
+}
+
+function countSeverities(findings) {
+ return findings.reduce((counts, finding) => {
+ counts[finding.severity] = (counts[finding.severity] || 0) + 1;
+ return counts;
+ }, {critical: 0, major: 0, minor: 0});
+}
+
+function evaluateSpecimen(specimen, findings, requiredActions) {
+ const target = specimen.id || specimen.label || 'specimen';
+
+ if (!hasText(specimen.source)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-specimen-source',
+ severity: 'critical',
+ target,
+ message: `${target} is missing source, accession, facility, or cohort provenance`,
+ },
+ reviewerAction(
+ 'request_specimen_source',
+ target,
+ 'reviewers need specimen origin provenance before trusting biomethods claims'
+ )
+ );
+ }
+
+ const missingContext = ['organism', 'strain', 'sex', 'age']
+ .filter((field) => !hasText(specimen[field]));
+ if (missingContext.length > 0) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-specimen-context',
+ severity: 'major',
+ target,
+ missingContext,
+ message: `${target} lacks biological context fields: ${missingContext.join(', ')}`,
+ },
+ reviewerAction(
+ 'request_specimen_context',
+ target,
+ 'organism, strain, sex, and age context are needed for biology peer-review templates'
+ )
+ );
+ }
+}
+
+function evaluateCellLine(cellLine, generatedAt, findings, requiredActions) {
+ const target = cellLine.id || cellLine.name || 'cell-line';
+ if (!hasText(cellLine.source)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-cell-line-source',
+ severity: 'major',
+ target,
+ message: `${target} is missing source or repository provenance`,
+ },
+ reviewerAction(
+ 'record_cell_line_source',
+ target,
+ 'cell-line source must be traceable for reproducibility and contamination checks'
+ )
+ );
+ }
+
+ if (!hasText(cellLine.authenticatedAt) || !hasText(cellLine.authenticationMethod)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-cell-line-authentication',
+ severity: 'critical',
+ target,
+ message: `${target} lacks authentication date or method evidence`,
+ },
+ reviewerAction(
+ 'provide_cell_line_authentication',
+ target,
+ 'cell-line identity needs authentication evidence before review release'
+ )
+ );
+ } else if (fullDaysBetween(cellLine.authenticatedAt, generatedAt) > 365) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'stale-cell-line-authentication',
+ severity: 'critical',
+ target,
+ authenticatedAt: cellLine.authenticatedAt,
+ message: `${target} authentication evidence is older than 365 days`,
+ },
+ reviewerAction(
+ 'refresh_cell_line_authentication',
+ target,
+ 'stale identity evidence can invalidate downstream biological conclusions'
+ )
+ );
+ }
+
+ if (!hasText(cellLine.mycoplasmaTestedAt)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-mycoplasma-evidence',
+ severity: 'major',
+ target,
+ message: `${target} lacks mycoplasma test evidence`,
+ },
+ reviewerAction(
+ 'provide_mycoplasma_test',
+ target,
+ 'cell-culture studies need contamination-screening evidence'
+ )
+ );
+ }
+}
+
+function evaluateReagent(reagent, findings, requiredActions) {
+ const target = reagent.id || reagent.name || 'reagent';
+ if (!hasText(reagent.vendor) || !hasText(reagent.catalogNumber)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-reagent-identifier',
+ severity: 'major',
+ target,
+ message: `${target} lacks vendor or catalog-number traceability`,
+ },
+ reviewerAction(
+ 'record_reagent_identifier',
+ target,
+ 'critical reagents need vendor and catalog identifiers for repeatability'
+ )
+ );
+ }
+
+ if (!hasText(reagent.lot)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-reagent-lot',
+ severity: 'major',
+ target,
+ message: `${target} lacks batch or lot evidence`,
+ },
+ reviewerAction(
+ 'record_reagent_lot',
+ target,
+ 'batch-sensitive assays need lot traceability before peer review'
+ )
+ );
+ }
+
+ if (String(reagent.type || '').toLowerCase() === 'antibody' && !hasText(reagent.rrid)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-antibody-rrid',
+ severity: 'major',
+ target,
+ message: `${target} is an antibody without RRID evidence`,
+ },
+ reviewerAction(
+ 'record_antibody_rrid',
+ target,
+ 'antibody-based claims need RRID evidence for reviewer verification'
+ )
+ );
+ }
+}
+
+function evaluateBatchConflicts(reagents, findings, requiredActions) {
+ const groups = new Map();
+ for (const reagent of reagents) {
+ if (!hasText(reagent.lot) || !hasText(reagent.vendor) || !hasText(reagent.catalogNumber)) {
+ continue;
+ }
+ const key = reagentKey(reagent);
+ if (!groups.has(key)) {
+ groups.set(key, []);
+ }
+ groups.get(key).push(reagent);
+ }
+
+ for (const entries of groups.values()) {
+ const lots = [...new Set(entries.map((entry) => entry.lot))].sort();
+ if (lots.length < 2) {
+ continue;
+ }
+ const target = entries[0].catalogNumber || entries[0].name;
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'reagent-batch-conflict',
+ severity: 'major',
+ target,
+ lots,
+ methodSections: entries.map((entry) => entry.methodSection).filter(hasText),
+ message: `${target} appears with multiple lots without batch-effect notes`,
+ },
+ reviewerAction(
+ 'explain_reagent_batch_handling',
+ target,
+ 'multi-lot reagents need batch-effect handling before claims are compared'
+ )
+ );
+ }
+}
+
+function decide(findings) {
+ if (findings.some((finding) => finding.severity === 'critical')) {
+ return 'hold-for-author-response';
+ }
+ if (findings.some((finding) => finding.severity === 'major')) {
+ return 'needs-provenance-review';
+ }
+ return 'ready-for-review';
+}
+
+function evaluateBiomethodsProvenance(input) {
+ const findings = [];
+ const requiredActions = [];
+ const generatedAt = input.generatedAt || new Date(0).toISOString();
+ const specimens = list(input.specimens);
+ const cellLines = list(input.cellLines);
+ const reagents = list(input.reagents);
+
+ for (const specimen of specimens) {
+ evaluateSpecimen(specimen, findings, requiredActions);
+ }
+ for (const cellLine of cellLines) {
+ evaluateCellLine(cellLine, generatedAt, findings, requiredActions);
+ }
+ for (const reagent of reagents) {
+ evaluateReagent(reagent, findings, requiredActions);
+ }
+ evaluateBatchConflicts(reagents, findings, requiredActions);
+
+ const summary = {
+ ...countSeverities(findings),
+ specimens: specimens.length,
+ cellLines: cellLines.length,
+ reagents: reagents.length,
+ findings: findings.length,
+ };
+
+ return {
+ manuscriptId: input.manuscriptId || 'unknown-manuscript',
+ generatedAt,
+ decision: decide(findings),
+ summary,
+ findings,
+ requiredActions,
+ };
+}
+
+function buildReviewerReport(result) {
+ const lines = [
+ '# Biomethods Provenance Assistant',
+ '',
+ `Manuscript: ${result.manuscriptId}`,
+ `Decision: ${result.decision}`,
+ `Generated: ${result.generatedAt}`,
+ '',
+ '## Summary',
+ '',
+ `- Critical findings: ${result.summary.critical || 0}`,
+ `- Major findings: ${result.summary.major || 0}`,
+ `- Minor findings: ${result.summary.minor || 0}`,
+ `- Specimens checked: ${result.summary.specimens || 0}`,
+ `- Cell lines checked: ${result.summary.cellLines || 0}`,
+ `- Reagents checked: ${result.summary.reagents || 0}`,
+ '',
+ ];
+
+ if (result.findings.length === 0) {
+ lines.push('No blocking biomethods provenance gaps detected.');
+ } else {
+ lines.push('## Findings', '');
+ for (const finding of result.findings) {
+ lines.push(`- **${finding.severity}** ${finding.type}: ${finding.message}`);
+ }
+ lines.push('', '## Required Actions', '');
+ for (const action of result.requiredActions) {
+ lines.push(`- ${action.type} (${action.target}): ${action.reason}`);
+ }
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+module.exports = {
+ evaluateBiomethodsProvenance,
+ buildReviewerReport,
+};
diff --git a/biomethods-provenance-assistant/reports/biomethods-provenance-packet.json b/biomethods-provenance-assistant/reports/biomethods-provenance-packet.json
new file mode 100644
index 00000000..6ffbe863
--- /dev/null
+++ b/biomethods-provenance-assistant/reports/biomethods-provenance-packet.json
@@ -0,0 +1,148 @@
+[
+ {
+ "scenario": "author-response-needed",
+ "manuscriptId": "ms-organoid-drug-response",
+ "generatedAt": "2026-05-22T14:00:00Z",
+ "decision": "hold-for-author-response",
+ "summary": {
+ "critical": 2,
+ "major": 4,
+ "minor": 0,
+ "specimens": 1,
+ "cellLines": 1,
+ "reagents": 1,
+ "findings": 6
+ },
+ "findings": [
+ {
+ "type": "missing-specimen-source",
+ "severity": "critical",
+ "target": "spec-1",
+ "message": "spec-1 is missing source, accession, facility, or cohort provenance"
+ },
+ {
+ "type": "missing-specimen-context",
+ "severity": "major",
+ "target": "spec-1",
+ "missingContext": [
+ "strain",
+ "sex",
+ "age"
+ ],
+ "message": "spec-1 lacks biological context fields: strain, sex, age"
+ },
+ {
+ "type": "stale-cell-line-authentication",
+ "severity": "critical",
+ "target": "cl-hek293t",
+ "authenticatedAt": "2024-01-01T00:00:00Z",
+ "message": "cl-hek293t authentication evidence is older than 365 days"
+ },
+ {
+ "type": "missing-mycoplasma-evidence",
+ "severity": "major",
+ "target": "cl-hek293t",
+ "message": "cl-hek293t lacks mycoplasma test evidence"
+ },
+ {
+ "type": "missing-reagent-lot",
+ "severity": "major",
+ "target": "ab-gapdh",
+ "message": "ab-gapdh lacks batch or lot evidence"
+ },
+ {
+ "type": "missing-antibody-rrid",
+ "severity": "major",
+ "target": "ab-gapdh",
+ "message": "ab-gapdh is an antibody without RRID evidence"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "request_specimen_source",
+ "target": "spec-1",
+ "reason": "reviewers need specimen origin provenance before trusting biomethods claims"
+ },
+ {
+ "type": "request_specimen_context",
+ "target": "spec-1",
+ "reason": "organism, strain, sex, and age context are needed for biology peer-review templates"
+ },
+ {
+ "type": "refresh_cell_line_authentication",
+ "target": "cl-hek293t",
+ "reason": "stale identity evidence can invalidate downstream biological conclusions"
+ },
+ {
+ "type": "provide_mycoplasma_test",
+ "target": "cl-hek293t",
+ "reason": "cell-culture studies need contamination-screening evidence"
+ },
+ {
+ "type": "record_reagent_lot",
+ "target": "ab-gapdh",
+ "reason": "batch-sensitive assays need lot traceability before peer review"
+ },
+ {
+ "type": "record_antibody_rrid",
+ "target": "ab-gapdh",
+ "reason": "antibody-based claims need RRID evidence for reviewer verification"
+ }
+ ]
+ },
+ {
+ "scenario": "batch-review-needed",
+ "manuscriptId": "ms-batch-sensitive-assay",
+ "generatedAt": "2026-05-22T14:00:00Z",
+ "decision": "needs-provenance-review",
+ "summary": {
+ "critical": 0,
+ "major": 1,
+ "minor": 0,
+ "specimens": 0,
+ "cellLines": 0,
+ "reagents": 2,
+ "findings": 1
+ },
+ "findings": [
+ {
+ "type": "reagent-batch-conflict",
+ "severity": "major",
+ "target": "ELISA-IL6",
+ "lots": [
+ "lot-a",
+ "lot-b"
+ ],
+ "methodSections": [
+ "Figure 2",
+ "Figure 4"
+ ],
+ "message": "ELISA-IL6 appears with multiple lots without batch-effect notes"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "explain_reagent_batch_handling",
+ "target": "ELISA-IL6",
+ "reason": "multi-lot reagents need batch-effect handling before claims are compared"
+ }
+ ]
+ },
+ {
+ "scenario": "ready-for-review",
+ "manuscriptId": "ms-complete-biomethods",
+ "generatedAt": "2026-05-22T14:00:00Z",
+ "decision": "ready-for-review",
+ "summary": {
+ "critical": 0,
+ "major": 0,
+ "minor": 0,
+ "specimens": 1,
+ "cellLines": 1,
+ "reagents": 1,
+ "findings": 0
+ },
+ "findings": [],
+ "requiredActions": []
+ }
+]
diff --git a/biomethods-provenance-assistant/reports/demo.mp4 b/biomethods-provenance-assistant/reports/demo.mp4
new file mode 100644
index 00000000..14db5cf4
Binary files /dev/null and b/biomethods-provenance-assistant/reports/demo.mp4 differ
diff --git a/biomethods-provenance-assistant/reports/reviewer-report.md b/biomethods-provenance-assistant/reports/reviewer-report.md
new file mode 100644
index 00000000..5861c18b
--- /dev/null
+++ b/biomethods-provenance-assistant/reports/reviewer-report.md
@@ -0,0 +1,74 @@
+# Biomethods Provenance Assistant
+
+Manuscript: ms-organoid-drug-response
+Decision: hold-for-author-response
+Generated: 2026-05-22T14:00:00Z
+
+## Summary
+
+- Critical findings: 2
+- Major findings: 4
+- Minor findings: 0
+- Specimens checked: 1
+- Cell lines checked: 1
+- Reagents checked: 1
+
+## Findings
+
+- **critical** missing-specimen-source: spec-1 is missing source, accession, facility, or cohort provenance
+- **major** missing-specimen-context: spec-1 lacks biological context fields: strain, sex, age
+- **critical** stale-cell-line-authentication: cl-hek293t authentication evidence is older than 365 days
+- **major** missing-mycoplasma-evidence: cl-hek293t lacks mycoplasma test evidence
+- **major** missing-reagent-lot: ab-gapdh lacks batch or lot evidence
+- **major** missing-antibody-rrid: ab-gapdh is an antibody without RRID evidence
+
+## Required Actions
+
+- request_specimen_source (spec-1): reviewers need specimen origin provenance before trusting biomethods claims
+- request_specimen_context (spec-1): organism, strain, sex, and age context are needed for biology peer-review templates
+- refresh_cell_line_authentication (cl-hek293t): stale identity evidence can invalidate downstream biological conclusions
+- provide_mycoplasma_test (cl-hek293t): cell-culture studies need contamination-screening evidence
+- record_reagent_lot (ab-gapdh): batch-sensitive assays need lot traceability before peer review
+- record_antibody_rrid (ab-gapdh): antibody-based claims need RRID evidence for reviewer verification
+
+---
+# Biomethods Provenance Assistant
+
+Manuscript: ms-batch-sensitive-assay
+Decision: needs-provenance-review
+Generated: 2026-05-22T14:00:00Z
+
+## Summary
+
+- Critical findings: 0
+- Major findings: 1
+- Minor findings: 0
+- Specimens checked: 0
+- Cell lines checked: 0
+- Reagents checked: 2
+
+## Findings
+
+- **major** reagent-batch-conflict: ELISA-IL6 appears with multiple lots without batch-effect notes
+
+## Required Actions
+
+- explain_reagent_batch_handling (ELISA-IL6): multi-lot reagents need batch-effect handling before claims are compared
+
+---
+# Biomethods Provenance Assistant
+
+Manuscript: ms-complete-biomethods
+Decision: ready-for-review
+Generated: 2026-05-22T14:00:00Z
+
+## Summary
+
+- Critical findings: 0
+- Major findings: 0
+- Minor findings: 0
+- Specimens checked: 1
+- Cell lines checked: 1
+- Reagents checked: 1
+
+No blocking biomethods provenance gaps detected.
diff --git a/biomethods-provenance-assistant/reports/summary.svg b/biomethods-provenance-assistant/reports/summary.svg
new file mode 100644
index 00000000..05a9aae5
--- /dev/null
+++ b/biomethods-provenance-assistant/reports/summary.svg
@@ -0,0 +1,23 @@
+
diff --git a/biomethods-provenance-assistant/sample-data.js b/biomethods-provenance-assistant/sample-data.js
new file mode 100644
index 00000000..0a4fd260
--- /dev/null
+++ b/biomethods-provenance-assistant/sample-data.js
@@ -0,0 +1,106 @@
+const scenarios = [
+ {
+ name: 'author-response-needed',
+ manuscriptId: 'ms-organoid-drug-response',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [
+ {
+ id: 'spec-1',
+ label: 'patient-derived pancreatic organoid line',
+ organism: 'Homo sapiens',
+ tissue: 'pancreatic tumor',
+ source: '',
+ strain: '',
+ sex: '',
+ age: '',
+ },
+ ],
+ cellLines: [
+ {
+ id: 'cl-hek293t',
+ name: 'HEK293T',
+ source: 'ATCC',
+ authenticatedAt: '2024-01-01T00:00:00Z',
+ authenticationMethod: 'STR',
+ mycoplasmaTestedAt: '',
+ },
+ ],
+ reagents: [
+ {
+ id: 'ab-gapdh',
+ name: 'anti-GAPDH antibody',
+ type: 'antibody',
+ vendor: 'ExampleBio',
+ catalogNumber: 'EB-100',
+ lot: '',
+ rrid: '',
+ },
+ ],
+ },
+ {
+ name: 'batch-review-needed',
+ manuscriptId: 'ms-batch-sensitive-assay',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [],
+ cellLines: [],
+ reagents: [
+ {
+ id: 'cytokine-a',
+ name: 'IL-6 ELISA kit',
+ type: 'assay-kit',
+ vendor: 'ExampleBio',
+ catalogNumber: 'ELISA-IL6',
+ lot: 'lot-a',
+ methodSection: 'Figure 2',
+ },
+ {
+ id: 'cytokine-b',
+ name: 'IL-6 ELISA kit',
+ type: 'assay-kit',
+ vendor: 'ExampleBio',
+ catalogNumber: 'ELISA-IL6',
+ lot: 'lot-b',
+ methodSection: 'Figure 4',
+ },
+ ],
+ },
+ {
+ name: 'ready-for-review',
+ manuscriptId: 'ms-complete-biomethods',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [
+ {
+ id: 'mouse-cohort-a',
+ label: 'treated mouse cohort',
+ organism: 'Mus musculus',
+ strain: 'C57BL/6J',
+ sex: 'female',
+ age: '10 weeks',
+ source: 'Institutional animal facility',
+ },
+ ],
+ cellLines: [
+ {
+ id: 'cl-a549',
+ name: 'A549',
+ source: 'ATCC',
+ authenticatedAt: '2026-02-12T00:00:00Z',
+ authenticationMethod: 'STR',
+ mycoplasmaTestedAt: '2026-04-01T00:00:00Z',
+ },
+ ],
+ reagents: [
+ {
+ id: 'ab-actin',
+ name: 'anti-beta-actin antibody',
+ type: 'antibody',
+ vendor: 'ExampleBio',
+ catalogNumber: 'EB-200',
+ lot: 'lot-42',
+ rrid: 'AB_123456',
+ },
+ ],
+ },
+];
+
+module.exports = {scenarios};
diff --git a/biomethods-provenance-assistant/test.js b/biomethods-provenance-assistant/test.js
new file mode 100644
index 00000000..c92828a0
--- /dev/null
+++ b/biomethods-provenance-assistant/test.js
@@ -0,0 +1,160 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ evaluateBiomethodsProvenance,
+ buildReviewerReport,
+} = require('./index');
+
+test('holds manuscript when biospecimen source and biological context are incomplete', () => {
+ const result = evaluateBiomethodsProvenance({
+ manuscriptId: 'ms-organoid-drug-response',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [
+ {
+ id: 'spec-1',
+ label: 'patient-derived organoid line',
+ organism: 'Homo sapiens',
+ tissue: 'pancreatic tumor',
+ source: '',
+ strain: '',
+ sex: '',
+ age: '',
+ },
+ ],
+ cellLines: [],
+ reagents: [],
+ });
+
+ assert.equal(result.decision, 'hold-for-author-response');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ [
+ 'missing-specimen-source',
+ 'missing-specimen-context',
+ ]
+ );
+ assert.equal(result.requiredActions.length, 2);
+});
+
+test('flags stale cell-line authentication and reagent traceability gaps', () => {
+ const result = evaluateBiomethodsProvenance({
+ manuscriptId: 'ms-immunoblot-screen',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [],
+ cellLines: [
+ {
+ id: 'cl-hek293t',
+ name: 'HEK293T',
+ source: 'ATCC',
+ authenticatedAt: '2024-01-01T00:00:00Z',
+ authenticationMethod: 'STR',
+ mycoplasmaTestedAt: '',
+ },
+ ],
+ reagents: [
+ {
+ id: 'ab-gapdh',
+ name: 'anti-GAPDH antibody',
+ type: 'antibody',
+ vendor: 'ExampleBio',
+ catalogNumber: 'EB-100',
+ lot: '',
+ rrid: '',
+ },
+ ],
+ });
+
+ assert.equal(result.decision, 'hold-for-author-response');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ [
+ 'stale-cell-line-authentication',
+ 'missing-mycoplasma-evidence',
+ 'missing-reagent-lot',
+ 'missing-antibody-rrid',
+ ]
+ );
+ assert.equal(result.summary.critical, 1);
+ assert.equal(result.summary.major, 3);
+});
+
+test('detects reagent batch conflicts across methods', () => {
+ const result = evaluateBiomethodsProvenance({
+ manuscriptId: 'ms-batch-sensitive-assay',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [],
+ cellLines: [],
+ reagents: [
+ {
+ id: 'cytokine-a',
+ name: 'IL-6 ELISA kit',
+ type: 'assay-kit',
+ vendor: 'ExampleBio',
+ catalogNumber: 'ELISA-IL6',
+ lot: 'lot-a',
+ methodSection: 'Figure 2',
+ },
+ {
+ id: 'cytokine-b',
+ name: 'IL-6 ELISA kit',
+ type: 'assay-kit',
+ vendor: 'ExampleBio',
+ catalogNumber: 'ELISA-IL6',
+ lot: 'lot-b',
+ methodSection: 'Figure 4',
+ },
+ ],
+ });
+
+ assert.equal(result.decision, 'needs-provenance-review');
+ assert.equal(result.findings.length, 1);
+ assert.equal(result.findings[0].type, 'reagent-batch-conflict');
+ assert.deepEqual(result.findings[0].lots, ['lot-a', 'lot-b']);
+});
+
+test('builds ready reviewer report when biomethods provenance is complete', () => {
+ const result = evaluateBiomethodsProvenance({
+ manuscriptId: 'ms-complete-biomethods',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [
+ {
+ id: 'mouse-cohort-a',
+ label: 'treated mouse cohort',
+ organism: 'Mus musculus',
+ strain: 'C57BL/6J',
+ sex: 'female',
+ age: '10 weeks',
+ source: 'Institutional animal facility',
+ },
+ ],
+ cellLines: [
+ {
+ id: 'cl-a549',
+ name: 'A549',
+ source: 'ATCC',
+ authenticatedAt: '2026-02-12T00:00:00Z',
+ authenticationMethod: 'STR',
+ mycoplasmaTestedAt: '2026-04-01T00:00:00Z',
+ },
+ ],
+ reagents: [
+ {
+ id: 'ab-actin',
+ name: 'anti-beta-actin antibody',
+ type: 'antibody',
+ vendor: 'ExampleBio',
+ catalogNumber: 'EB-200',
+ lot: 'lot-42',
+ rrid: 'AB_123456',
+ },
+ ],
+ });
+ const report = buildReviewerReport(result);
+
+ assert.equal(result.decision, 'ready-for-review');
+ assert.equal(result.findings.length, 0);
+ assert.match(report, /Biomethods Provenance Assistant/);
+ assert.match(report, /ready-for-review/);
+ assert.match(report, /No blocking biomethods provenance gaps detected/);
+});