diff --git a/citation-context-reconciler/README.md b/citation-context-reconciler/README.md
new file mode 100644
index 0000000..fb6a042
--- /dev/null
+++ b/citation-context-reconciler/README.md
@@ -0,0 +1,34 @@
+# Citation Context Reconciler
+
+`citation-context-reconciler` is a focused SCIBASE AI research assistant module for issue #16. It helps authors and reviewers catch citation drift before a manuscript leaves the workspace: sources cited as evidence are checked against the actual source context, effect direction, method match, population match, recency, and reproducibility artifacts.
+
+The slice is intentionally narrower than a broad copilot. It answers a practical question during pre-submission review: "Are these citations being used honestly enough for a reviewer, journal, funder, or replication team to trust the claim?"
+
+## What It Does
+
+- Reviews manuscript claims against cited source metadata.
+- Detects direct blockers such as contradictory cited effects, citation-intent mismatch, retracted citations, missing sources, and effect-direction mismatch.
+- Raises warnings for mixed evidence, stale citations, population/method context gaps, and missing raw data/code/protocol evidence.
+- Generates reviewer comments and author revision tasks.
+- Scores reproducibility support from raw data, code, and registered protocol availability.
+- Ranks research opportunities from contradictions, lab capabilities, and researcher interests.
+- Exports a deterministic assistant packet with an audit digest for handoff and version history.
+
+## Files
+
+- `src/citation-context-reconciler.js` - dependency-free reconciliation engine.
+- `sample/citation-context-packet.json` - synthetic manuscript/source packet.
+- `test/citation-context-reconciler.test.js` - executable behavioral tests.
+- `scripts/demo.js` - writes `docs/citation-context-report.json` and `docs/demo.svg`.
+- `scripts/render-demo-video.m` - macOS-native MP4 renderer for `docs/demo.mp4`.
+- `docs/requirement-map.md` - issue #16 capability mapping.
+
+## Validation
+
+```sh
+npm test
+npm run demo
+npm run demo:video
+```
+
+The demo uses only local synthetic data and does not call an AI provider or external service.
diff --git a/citation-context-reconciler/docs/citation-context-report.json b/citation-context-reconciler/docs/citation-context-report.json
new file mode 100644
index 0000000..d9defda
--- /dev/null
+++ b/citation-context-reconciler/docs/citation-context-report.json
@@ -0,0 +1,697 @@
+{
+ "ready": false,
+ "counts": {
+ "claims": 3,
+ "sources": 4,
+ "blockers": 5,
+ "warnings": 6,
+ "opportunities": 2
+ },
+ "findings": [
+ {
+ "severity": "blocker",
+ "code": "contradicting-source-used-as-support",
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "sourceIds": [
+ "src-recent-null-meta"
+ ],
+ "message": "src-recent-null-meta (2025) contradicts the claim but is cited without a limitation note.",
+ "task": "Move the source into a limitation sentence or add a rebuttal-backed explanation."
+ },
+ {
+ "severity": "blocker",
+ "code": "contradictory-cited-effects",
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "sourceIds": [
+ "src-legacy-pacing",
+ "src-recent-null-meta"
+ ],
+ "message": "The cited sources disagree on effect direction: lower, no-effect.",
+ "task": "Rewrite the claim as contested or add an adjudication note explaining why one direction is preferred."
+ },
+ {
+ "severity": "blocker",
+ "code": "effect-direction-mismatch",
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "sourceIds": [
+ "src-recent-null-meta"
+ ],
+ "message": "src-recent-null-meta (2025) has effect direction no-effect, but the claim states lower.",
+ "task": "Align the claim with the source or cite a direct source for the stated effect direction."
+ },
+ {
+ "severity": "blocker",
+ "code": "citation-intent-mismatch",
+ "claimId": "claim-pipeline-reproducible",
+ "section": "Methods",
+ "sourceIds": [
+ "src-workflow-note"
+ ],
+ "message": "src-workflow-note (2026) is tagged for method, not direct evidence.",
+ "task": "Use the citation as context only or replace it with a direct empirical support source."
+ },
+ {
+ "severity": "blocker",
+ "code": "citation-intent-mismatch",
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "sourceIds": [
+ "src-scrna-review"
+ ],
+ "message": "src-scrna-review (2022) is tagged for background, not direct evidence.",
+ "task": "Use the citation as context only or replace it with a direct empirical support source."
+ },
+ {
+ "severity": "warning",
+ "code": "population-context-gap",
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "sourceIds": [
+ "src-legacy-pacing"
+ ],
+ "message": "src-legacy-pacing (2014) does not cover the claim population: older-adults, post-viral-fatigue.",
+ "task": "Add population-matched evidence or narrow the claim."
+ },
+ {
+ "severity": "warning",
+ "code": "reproducibility-evidence-gap",
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "sourceIds": [
+ "src-legacy-pacing"
+ ],
+ "message": "src-legacy-pacing (2014) is missing reproducibility evidence: code, protocol.",
+ "task": "Link the missing artifacts or lower the reproducibility confidence for this claim."
+ },
+ {
+ "severity": "warning",
+ "code": "stale-citation",
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "sourceIds": [
+ "src-legacy-pacing"
+ ],
+ "message": "src-legacy-pacing (2014) is older than the configured 7-year recency window.",
+ "task": "Run a recency search and add a current synthesis or explain why the older source is canonical."
+ },
+ {
+ "severity": "warning",
+ "code": "reproducibility-evidence-gap",
+ "claimId": "claim-pipeline-reproducible",
+ "section": "Methods",
+ "sourceIds": [
+ "src-workflow-note"
+ ],
+ "message": "src-workflow-note (2026) is missing reproducibility evidence: raw data, protocol.",
+ "task": "Link the missing artifacts or lower the reproducibility confidence for this claim."
+ },
+ {
+ "severity": "warning",
+ "code": "mixed-source-needs-qualification",
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "sourceIds": [
+ "src-scrna-review"
+ ],
+ "message": "src-scrna-review (2022) reports mixed evidence and needs qualified language.",
+ "task": "Add uncertainty language and identify which subgroup or assay is supported."
+ },
+ {
+ "severity": "warning",
+ "code": "population-context-gap",
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "sourceIds": [
+ "src-scrna-review"
+ ],
+ "message": "src-scrna-review (2022) does not cover the claim population: post-viral-fatigue.",
+ "task": "Add population-matched evidence or narrow the claim."
+ }
+ ],
+ "reviewerQueue": [
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "blocker",
+ "body": "Resolve before release: src-recent-null-meta (2025) contradicts the claim but is cited without a limitation note."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "blocker",
+ "body": "Resolve before release: The cited sources disagree on effect direction: lower, no-effect."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "blocker",
+ "body": "Resolve before release: src-recent-null-meta (2025) has effect direction no-effect, but the claim states lower."
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "section": "Methods",
+ "severity": "blocker",
+ "body": "Resolve before release: src-workflow-note (2026) is tagged for method, not direct evidence."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "severity": "blocker",
+ "body": "Resolve before release: src-scrna-review (2022) is tagged for background, not direct evidence."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "warning",
+ "body": "Review before submission: src-legacy-pacing (2014) does not cover the claim population: older-adults, post-viral-fatigue."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "warning",
+ "body": "Review before submission: src-legacy-pacing (2014) is missing reproducibility evidence: code, protocol."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "warning",
+ "body": "Review before submission: src-legacy-pacing (2014) is older than the configured 7-year recency window."
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "section": "Methods",
+ "severity": "warning",
+ "body": "Review before submission: src-workflow-note (2026) is missing reproducibility evidence: raw data, protocol."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "severity": "warning",
+ "body": "Review before submission: src-scrna-review (2022) reports mixed evidence and needs qualified language."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "severity": "warning",
+ "body": "Review before submission: src-scrna-review (2022) does not cover the claim population: post-viral-fatigue."
+ }
+ ],
+ "revisionTasks": [
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "contradicting-source-used-as-support",
+ "ownerHint": "author",
+ "task": "Move the source into a limitation sentence or add a rebuttal-backed explanation."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "contradictory-cited-effects",
+ "ownerHint": "author",
+ "task": "Rewrite the claim as contested or add an adjudication note explaining why one direction is preferred."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "effect-direction-mismatch",
+ "ownerHint": "author",
+ "task": "Align the claim with the source or cite a direct source for the stated effect direction."
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "code": "citation-intent-mismatch",
+ "ownerHint": "author",
+ "task": "Use the citation as context only or replace it with a direct empirical support source."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "code": "citation-intent-mismatch",
+ "ownerHint": "author",
+ "task": "Use the citation as context only or replace it with a direct empirical support source."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "population-context-gap",
+ "ownerHint": "reviewer",
+ "task": "Add population-matched evidence or narrow the claim."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "reproducibility-evidence-gap",
+ "ownerHint": "reviewer",
+ "task": "Link the missing artifacts or lower the reproducibility confidence for this claim."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "stale-citation",
+ "ownerHint": "reviewer",
+ "task": "Run a recency search and add a current synthesis or explain why the older source is canonical."
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "code": "reproducibility-evidence-gap",
+ "ownerHint": "reviewer",
+ "task": "Link the missing artifacts or lower the reproducibility confidence for this claim."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "code": "mixed-source-needs-qualification",
+ "ownerHint": "reviewer",
+ "task": "Add uncertainty language and identify which subgroup or assay is supported."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "code": "population-context-gap",
+ "ownerHint": "reviewer",
+ "task": "Add population-matched evidence or narrow the claim."
+ }
+ ],
+ "citationMatrix": [
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "citedSources": [
+ {
+ "id": "src-legacy-pacing",
+ "stance": "supports",
+ "intent": "evidence",
+ "effectDirection": "lower",
+ "methodOverlap": [
+ "cytokine-panel"
+ ],
+ "populationOverlap": [],
+ "reproducibilityEvidence": {
+ "rawData": true,
+ "code": false,
+ "protocol": false
+ }
+ },
+ {
+ "id": "src-recent-null-meta",
+ "stance": "contradicts",
+ "intent": "limitation",
+ "effectDirection": "no-effect",
+ "methodOverlap": [
+ "wearable-sensor-cohort",
+ "cytokine-panel"
+ ],
+ "populationOverlap": [
+ "older-adults",
+ "post-viral-fatigue"
+ ],
+ "reproducibilityEvidence": {
+ "rawData": true,
+ "code": true,
+ "protocol": true
+ }
+ }
+ ]
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "citedSources": [
+ {
+ "id": "src-scrna-review",
+ "stance": "mixed",
+ "intent": "background",
+ "effectDirection": "mixed",
+ "methodOverlap": [
+ "single-cell-rna-seq"
+ ],
+ "populationOverlap": [],
+ "reproducibilityEvidence": {
+ "rawData": false,
+ "code": false,
+ "protocol": false
+ }
+ }
+ ]
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "section": "Methods",
+ "citedSources": [
+ {
+ "id": "src-workflow-note",
+ "stance": "supports",
+ "intent": "method",
+ "effectDirection": "unknown",
+ "methodOverlap": [
+ "open-analysis-pipeline"
+ ],
+ "populationOverlap": [
+ "post-viral-fatigue"
+ ],
+ "reproducibilityEvidence": {
+ "rawData": false,
+ "code": true,
+ "protocol": false
+ }
+ }
+ ]
+ }
+ ],
+ "reproducibility": {
+ "score": 50,
+ "claims": [
+ {
+ "claimId": "claim-cytokine-recovery",
+ "score": 67,
+ "required": true
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "score": 0,
+ "required": false
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "score": 33,
+ "required": true
+ }
+ ],
+ "confidence": "low"
+ },
+ "opportunityFeed": [
+ {
+ "id": "gap-older-adult-replication",
+ "title": "Replicate pacing cytokine effects in older wearable cohorts",
+ "priority": 17,
+ "capabilityOverlap": [
+ "wearable-sensor-cohort",
+ "open-analysis-pipeline"
+ ],
+ "interestOverlap": [
+ "post-viral-fatigue",
+ "cytokine-signaling",
+ "replication"
+ ],
+ "linkedBlockers": [
+ "claim-cytokine-recovery"
+ ],
+ "nextAction": "Run a preregistered replication with age-stratified cytokine endpoints."
+ },
+ {
+ "id": "gap-crispr-validation",
+ "title": "Validate single-cell pathway claims with direct CRISPR evidence",
+ "priority": 11,
+ "capabilityOverlap": [
+ "single-cell-rna-seq"
+ ],
+ "interestOverlap": [
+ "cytokine-signaling",
+ "replication"
+ ],
+ "linkedBlockers": [
+ "claim-scrna-pathway"
+ ],
+ "nextAction": "Search for direct perturbation studies or mark the pathway as hypothesis-generating."
+ }
+ ],
+ "exportPacket": {
+ "projectId": "scibase-ms-42",
+ "generatedAt": "2026-05-16T18:20:00.000Z",
+ "citationMatrix": [
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "citedSources": [
+ {
+ "id": "src-legacy-pacing",
+ "stance": "supports",
+ "intent": "evidence",
+ "effectDirection": "lower",
+ "methodOverlap": [
+ "cytokine-panel"
+ ],
+ "populationOverlap": [],
+ "reproducibilityEvidence": {
+ "rawData": true,
+ "code": false,
+ "protocol": false
+ }
+ },
+ {
+ "id": "src-recent-null-meta",
+ "stance": "contradicts",
+ "intent": "limitation",
+ "effectDirection": "no-effect",
+ "methodOverlap": [
+ "wearable-sensor-cohort",
+ "cytokine-panel"
+ ],
+ "populationOverlap": [
+ "older-adults",
+ "post-viral-fatigue"
+ ],
+ "reproducibilityEvidence": {
+ "rawData": true,
+ "code": true,
+ "protocol": true
+ }
+ }
+ ]
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "citedSources": [
+ {
+ "id": "src-scrna-review",
+ "stance": "mixed",
+ "intent": "background",
+ "effectDirection": "mixed",
+ "methodOverlap": [
+ "single-cell-rna-seq"
+ ],
+ "populationOverlap": [],
+ "reproducibilityEvidence": {
+ "rawData": false,
+ "code": false,
+ "protocol": false
+ }
+ }
+ ]
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "section": "Methods",
+ "citedSources": [
+ {
+ "id": "src-workflow-note",
+ "stance": "supports",
+ "intent": "method",
+ "effectDirection": "unknown",
+ "methodOverlap": [
+ "open-analysis-pipeline"
+ ],
+ "populationOverlap": [
+ "post-viral-fatigue"
+ ],
+ "reproducibilityEvidence": {
+ "rawData": false,
+ "code": true,
+ "protocol": false
+ }
+ }
+ ]
+ }
+ ],
+ "reviewerQueue": [
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "blocker",
+ "body": "Resolve before release: src-recent-null-meta (2025) contradicts the claim but is cited without a limitation note."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "blocker",
+ "body": "Resolve before release: The cited sources disagree on effect direction: lower, no-effect."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "blocker",
+ "body": "Resolve before release: src-recent-null-meta (2025) has effect direction no-effect, but the claim states lower."
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "section": "Methods",
+ "severity": "blocker",
+ "body": "Resolve before release: src-workflow-note (2026) is tagged for method, not direct evidence."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "severity": "blocker",
+ "body": "Resolve before release: src-scrna-review (2022) is tagged for background, not direct evidence."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "warning",
+ "body": "Review before submission: src-legacy-pacing (2014) does not cover the claim population: older-adults, post-viral-fatigue."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "warning",
+ "body": "Review before submission: src-legacy-pacing (2014) is missing reproducibility evidence: code, protocol."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "section": "Results",
+ "severity": "warning",
+ "body": "Review before submission: src-legacy-pacing (2014) is older than the configured 7-year recency window."
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "section": "Methods",
+ "severity": "warning",
+ "body": "Review before submission: src-workflow-note (2026) is missing reproducibility evidence: raw data, protocol."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "severity": "warning",
+ "body": "Review before submission: src-scrna-review (2022) reports mixed evidence and needs qualified language."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "section": "Discussion",
+ "severity": "warning",
+ "body": "Review before submission: src-scrna-review (2022) does not cover the claim population: post-viral-fatigue."
+ }
+ ],
+ "revisionTasks": [
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "contradicting-source-used-as-support",
+ "ownerHint": "author",
+ "task": "Move the source into a limitation sentence or add a rebuttal-backed explanation."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "contradictory-cited-effects",
+ "ownerHint": "author",
+ "task": "Rewrite the claim as contested or add an adjudication note explaining why one direction is preferred."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "effect-direction-mismatch",
+ "ownerHint": "author",
+ "task": "Align the claim with the source or cite a direct source for the stated effect direction."
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "code": "citation-intent-mismatch",
+ "ownerHint": "author",
+ "task": "Use the citation as context only or replace it with a direct empirical support source."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "code": "citation-intent-mismatch",
+ "ownerHint": "author",
+ "task": "Use the citation as context only or replace it with a direct empirical support source."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "population-context-gap",
+ "ownerHint": "reviewer",
+ "task": "Add population-matched evidence or narrow the claim."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "reproducibility-evidence-gap",
+ "ownerHint": "reviewer",
+ "task": "Link the missing artifacts or lower the reproducibility confidence for this claim."
+ },
+ {
+ "claimId": "claim-cytokine-recovery",
+ "code": "stale-citation",
+ "ownerHint": "reviewer",
+ "task": "Run a recency search and add a current synthesis or explain why the older source is canonical."
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "code": "reproducibility-evidence-gap",
+ "ownerHint": "reviewer",
+ "task": "Link the missing artifacts or lower the reproducibility confidence for this claim."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "code": "mixed-source-needs-qualification",
+ "ownerHint": "reviewer",
+ "task": "Add uncertainty language and identify which subgroup or assay is supported."
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "code": "population-context-gap",
+ "ownerHint": "reviewer",
+ "task": "Add population-matched evidence or narrow the claim."
+ }
+ ],
+ "reproducibility": {
+ "score": 50,
+ "claims": [
+ {
+ "claimId": "claim-cytokine-recovery",
+ "score": 67,
+ "required": true
+ },
+ {
+ "claimId": "claim-scrna-pathway",
+ "score": 0,
+ "required": false
+ },
+ {
+ "claimId": "claim-pipeline-reproducible",
+ "score": 33,
+ "required": true
+ }
+ ],
+ "confidence": "low"
+ },
+ "opportunityFeed": [
+ {
+ "id": "gap-older-adult-replication",
+ "title": "Replicate pacing cytokine effects in older wearable cohorts",
+ "priority": 17,
+ "capabilityOverlap": [
+ "wearable-sensor-cohort",
+ "open-analysis-pipeline"
+ ],
+ "interestOverlap": [
+ "post-viral-fatigue",
+ "cytokine-signaling",
+ "replication"
+ ],
+ "linkedBlockers": [
+ "claim-cytokine-recovery"
+ ],
+ "nextAction": "Run a preregistered replication with age-stratified cytokine endpoints."
+ },
+ {
+ "id": "gap-crispr-validation",
+ "title": "Validate single-cell pathway claims with direct CRISPR evidence",
+ "priority": 11,
+ "capabilityOverlap": [
+ "single-cell-rna-seq"
+ ],
+ "interestOverlap": [
+ "cytokine-signaling",
+ "replication"
+ ],
+ "linkedBlockers": [
+ "claim-scrna-pathway"
+ ],
+ "nextAction": "Search for direct perturbation studies or mark the pathway as hypothesis-generating."
+ }
+ ],
+ "auditDigest": "citation-context:5:6:c4998c5dea561415"
+ }
+}
diff --git a/citation-context-reconciler/docs/demo.mp4 b/citation-context-reconciler/docs/demo.mp4
new file mode 100644
index 0000000..b86c10c
Binary files /dev/null and b/citation-context-reconciler/docs/demo.mp4 differ
diff --git a/citation-context-reconciler/docs/demo.svg b/citation-context-reconciler/docs/demo.svg
new file mode 100644
index 0000000..71a9bb0
--- /dev/null
+++ b/citation-context-reconciler/docs/demo.svg
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/citation-context-reconciler/docs/requirement-map.md b/citation-context-reconciler/docs/requirement-map.md
new file mode 100644
index 0000000..1c9597f
--- /dev/null
+++ b/citation-context-reconciler/docs/requirement-map.md
@@ -0,0 +1,14 @@
+# Requirement Map
+
+This module targets SCIBASE issue #16, "AI-Powered Research Assistant Suite", with a narrow citation-context reconciliation slice.
+
+| Issue requirement | Implemented support |
+| --- | --- |
+| Auto peer review reports | Generates reviewer queue comments for citation misuse, contradictory evidence, stale support, method/population drift, and reproducibility gaps. |
+| Claims vs. evidence alignment | Builds a citation matrix that compares manuscript claims with cited source stance, effect direction, citation intent, method tags, population tags, and artifact availability. |
+| Missing citations or scope misalignment | Flags claims without citations, dangling source IDs, background/method citations used as direct evidence, method-context gaps, and population-context gaps. |
+| Reproducibility checker | Computes claim-level and aggregate reproducibility confidence from raw data, code, and protocol evidence attached to cited sources. |
+| Research gap finder | Ranks opportunity cards from contradiction signals, blocked claim links, lab capabilities, and researcher interests. |
+| Reviewer-ready handoff | Exports deterministic revision tasks, review comments, citation matrix, opportunity feed, reproducibility score, and an audit digest. |
+
+The implementation is dependency-free, synthetic-data-only, and does not require external APIs, credentials, model calls, or private data.
diff --git a/citation-context-reconciler/package.json b/citation-context-reconciler/package.json
new file mode 100644
index 0000000..4a1513b
--- /dev/null
+++ b/citation-context-reconciler/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "citation-context-reconciler",
+ "version": "1.0.0",
+ "description": "Citation-context reconciliation assistant for SCIBASE AI-powered research workflows.",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "test": "node test/citation-context-reconciler.test.js",
+ "demo": "node scripts/demo.js",
+ "demo:video": "mkdir -p docs && clang -fobjc-arc -framework Foundation -framework AppKit -framework AVFoundation -framework CoreMedia -framework CoreVideo scripts/render-demo-video.m -o /tmp/citation-context-demo && /tmp/citation-context-demo docs/demo.mp4"
+ },
+ "exports": {
+ ".": "./src/citation-context-reconciler.js"
+ }
+}
diff --git a/citation-context-reconciler/sample/citation-context-packet.json b/citation-context-reconciler/sample/citation-context-packet.json
new file mode 100644
index 0000000..77bc840
--- /dev/null
+++ b/citation-context-reconciler/sample/citation-context-packet.json
@@ -0,0 +1,128 @@
+{
+ "generatedAt": "2026-05-16T18:20:00.000Z",
+ "project": {
+ "id": "scibase-ms-42",
+ "title": "Immune signatures in wearable-guided recovery cohorts",
+ "field": "translational immunology"
+ },
+ "lab": {
+ "capabilities": ["single-cell-rna-seq", "wearable-sensor-cohort", "open-analysis-pipeline"],
+ "interests": ["post-viral-fatigue", "cytokine-signaling", "replication"]
+ },
+ "claims": [
+ {
+ "id": "claim-cytokine-recovery",
+ "section": "Results",
+ "text": "Wearable-guided pacing lowers inflammatory cytokine burden in older post-viral recovery cohorts.",
+ "citedSourceIds": ["src-legacy-pacing", "src-recent-null-meta"],
+ "methodTags": ["wearable-sensor-cohort", "cytokine-panel"],
+ "populations": ["older-adults", "post-viral-fatigue"],
+ "effectDirection": "lower",
+ "usesCitationAs": "evidence",
+ "requiresCurrentEvidence": true,
+ "requiresReproducibilityEvidence": true
+ },
+ {
+ "id": "claim-scrna-pathway",
+ "section": "Discussion",
+ "text": "Single-cell CRISPR screens identify the same cytokine pathway as causal.",
+ "citedSourceIds": ["src-scrna-review"],
+ "methodTags": ["single-cell-rna-seq", "crispr-screen"],
+ "populations": ["post-viral-fatigue"],
+ "effectDirection": "higher",
+ "usesCitationAs": "evidence",
+ "requiresCurrentEvidence": true,
+ "requiresReproducibilityEvidence": false
+ },
+ {
+ "id": "claim-pipeline-reproducible",
+ "section": "Methods",
+ "text": "The analysis pipeline is fully reproducible from raw data and registered protocols.",
+ "citedSourceIds": ["src-workflow-note"],
+ "methodTags": ["open-analysis-pipeline"],
+ "populations": ["post-viral-fatigue"],
+ "effectDirection": "unknown",
+ "usesCitationAs": "evidence",
+ "requiresCurrentEvidence": false,
+ "requiresReproducibilityEvidence": true
+ }
+ ],
+ "sources": [
+ {
+ "id": "src-legacy-pacing",
+ "title": "Small pilot of pacing interventions after viral illness",
+ "year": 2014,
+ "stance": "supports",
+ "citationIntent": "evidence",
+ "effectDirection": "lower",
+ "methodTags": ["cytokine-panel"],
+ "populations": ["young-adults"],
+ "hasRawData": true,
+ "hasCode": false,
+ "hasProtocol": false,
+ "retracted": false
+ },
+ {
+ "id": "src-recent-null-meta",
+ "title": "Meta-analysis of pacing and cytokine biomarkers",
+ "year": 2025,
+ "stance": "contradicts",
+ "citationIntent": "limitation",
+ "effectDirection": "no-effect",
+ "methodTags": ["wearable-sensor-cohort", "cytokine-panel"],
+ "populations": ["older-adults", "post-viral-fatigue"],
+ "hasRawData": true,
+ "hasCode": true,
+ "hasProtocol": true,
+ "retracted": false
+ },
+ {
+ "id": "src-scrna-review",
+ "title": "Review of single-cell immune atlas methods",
+ "year": 2022,
+ "stance": "mixed",
+ "citationIntent": "background",
+ "effectDirection": "mixed",
+ "methodTags": ["single-cell-rna-seq"],
+ "populations": ["autoimmune-disease"],
+ "hasRawData": false,
+ "hasCode": false,
+ "hasProtocol": false,
+ "retracted": false
+ },
+ {
+ "id": "src-workflow-note",
+ "title": "Internal workflow note for cohort preprocessing",
+ "year": 2026,
+ "stance": "supports",
+ "citationIntent": "method",
+ "effectDirection": "unknown",
+ "methodTags": ["open-analysis-pipeline"],
+ "populations": ["post-viral-fatigue"],
+ "hasRawData": false,
+ "hasCode": true,
+ "hasProtocol": false,
+ "retracted": false
+ }
+ ],
+ "corpusGaps": [
+ {
+ "id": "gap-older-adult-replication",
+ "title": "Replicate pacing cytokine effects in older wearable cohorts",
+ "topicTags": ["post-viral-fatigue", "cytokine-signaling", "replication"],
+ "requiredCapabilities": ["wearable-sensor-cohort", "open-analysis-pipeline"],
+ "linkedClaimIds": ["claim-cytokine-recovery"],
+ "contradictionSignals": ["recent-null-meta-analysis", "population-mismatch"],
+ "nextAction": "Run a preregistered replication with age-stratified cytokine endpoints."
+ },
+ {
+ "id": "gap-crispr-validation",
+ "title": "Validate single-cell pathway claims with direct CRISPR evidence",
+ "topicTags": ["cytokine-signaling", "replication"],
+ "requiredCapabilities": ["single-cell-rna-seq"],
+ "linkedClaimIds": ["claim-scrna-pathway"],
+ "contradictionSignals": ["background-only-citation"],
+ "nextAction": "Search for direct perturbation studies or mark the pathway as hypothesis-generating."
+ }
+ ]
+}
diff --git a/citation-context-reconciler/scripts/demo.js b/citation-context-reconciler/scripts/demo.js
new file mode 100644
index 0000000..d93dd8b
--- /dev/null
+++ b/citation-context-reconciler/scripts/demo.js
@@ -0,0 +1,64 @@
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { reconcileCitationContext } from "../src/citation-context-reconciler.js";
+
+const fixture = JSON.parse(readFileSync(new URL("../sample/citation-context-packet.json", import.meta.url), "utf8"));
+const audit = reconcileCitationContext(fixture);
+
+mkdirSync(new URL("../docs/", import.meta.url), { recursive: true });
+writeFileSync(
+ new URL("../docs/citation-context-report.json", import.meta.url),
+ `${JSON.stringify(audit, null, 2)}\n`
+);
+
+const blockerRows = audit.findings
+ .filter((item) => item.severity === "blocker")
+ .slice(0, 4)
+ .map((item, index) => {
+ const y = 228 + index * 42;
+ return `${item.code}: ${item.claimId}`;
+ })
+ .join("\n");
+
+const svg = ``;
+
+writeFileSync(new URL("../docs/demo.svg", import.meta.url), svg);
+
+console.log(`ready: ${audit.ready}`);
+console.log(`blockers: ${audit.counts.blockers}`);
+console.log(`warnings: ${audit.counts.warnings}`);
+console.log(`reproducibility: ${audit.reproducibility.score}% ${audit.reproducibility.confidence}`);
+console.log(`top opportunity: ${audit.opportunityFeed[0]?.id ?? "none"}`);
+console.log(`digest: ${audit.exportPacket.auditDigest}`);
diff --git a/citation-context-reconciler/scripts/render-demo-video.m b/citation-context-reconciler/scripts/render-demo-video.m
new file mode 100644
index 0000000..ff7457d
--- /dev/null
+++ b/citation-context-reconciler/scripts/render-demo-video.m
@@ -0,0 +1,166 @@
+#import
+#import
+
+static const NSInteger FrameWidth = 1280;
+static const NSInteger FrameHeight = 720;
+static const NSInteger FramesPerSecond = 30;
+static const NSInteger TotalFrames = 150;
+
+static NSColor *Color(CGFloat red, CGFloat green, CGFloat blue) {
+ return [NSColor colorWithCalibratedRed:red / 255.0 green:green / 255.0 blue:blue / 255.0 alpha:1.0];
+}
+
+static void FillRounded(NSRect rect, CGFloat radius, NSColor *color) {
+ NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:radius yRadius:radius];
+ [color setFill];
+ [path fill];
+}
+
+static void DrawText(NSString *text, CGFloat x, CGFloat y, CGFloat width, CGFloat height, CGFloat size, NSColor *color, BOOL bold) {
+ NSFont *font = bold ? [NSFont systemFontOfSize:size weight:NSFontWeightSemibold] : [NSFont systemFontOfSize:size weight:NSFontWeightRegular];
+ NSDictionary *attributes = @{
+ NSFontAttributeName: font,
+ NSForegroundColorAttributeName: color
+ };
+ [text drawInRect:NSMakeRect(x, y, width, height) withAttributes:attributes];
+}
+
+static void DrawFrame(CGContextRef context, NSInteger frameIndex) {
+ NSGraphicsContext *graphicsContext = [NSGraphicsContext graphicsContextWithCGContext:context flipped:NO];
+ [NSGraphicsContext saveGraphicsState];
+ [NSGraphicsContext setCurrentContext:graphicsContext];
+
+ [[NSColor colorWithCalibratedRed:248.0 / 255.0 green:245.0 / 255.0 blue:238.0 / 255.0 alpha:1.0] setFill];
+ NSRectFill(NSMakeRect(0, 0, FrameWidth, FrameHeight));
+
+ FillRounded(NSMakeRect(52, 46, 1176, 628), 20, [NSColor whiteColor]);
+ DrawText(@"Citation Context Reconciler", 88, 604, 720, 44, 36, Color(38, 34, 29), YES);
+ DrawText(@"SCIBASE issue #16 assistant slice: citation drift, contradictions, and reproducibility support.", 88, 570, 960, 28, 18, Color(100, 92, 82), NO);
+
+ CGFloat progress = (CGFloat)frameIndex / (CGFloat)(TotalFrames - 1);
+ FillRounded(NSMakeRect(88, 532, 1088, 12), 6, Color(224, 218, 207));
+ FillRounded(NSMakeRect(88, 532, 1088 * progress, 12), 6, Color(43, 124, 132));
+
+ NSArray *labels = @[@"Claims", @"Blockers", @"Warnings", @"Repro"];
+ NSArray *values = @[@"3", @"5", @"6", @"50%"];
+ NSArray *details = @[@"reviewed", @"must fix", @"review notes", @"medium"];
+ for (NSInteger index = 0; index < 4; index++) {
+ CGFloat x = 88 + index * 270;
+ FillRounded(NSMakeRect(x, 372, 238, 130), 14, Color(241, 237, 228));
+ DrawText(labels[index], x + 22, 458, 180, 24, 16, Color(100, 92, 82), YES);
+ DrawText(values[index], x + 22, 416, 160, 40, 31, Color(38, 34, 29), YES);
+ DrawText(details[index], x + 22, 394, 180, 22, 14, Color(116, 107, 96), NO);
+ }
+
+ FillRounded(NSMakeRect(88, 142, 1088, 184), 18, Color(23, 33, 43));
+ DrawText(@"Reviewer queue", 124, 278, 260, 32, 23, [NSColor whiteColor], YES);
+ DrawText(@"contradictory-cited-effects: claim-cytokine-recovery", 124, 238, 720, 26, 17, Color(231, 242, 245), NO);
+ DrawText(@"citation-intent-mismatch: claim-scrna-pathway", 124, 204, 720, 26, 17, Color(231, 242, 245), NO);
+ DrawText(@"reproducibility-evidence-gap: claim-pipeline-reproducible", 124, 170, 760, 26, 17, Color(231, 242, 245), NO);
+
+ CGFloat pulse = 0.42 + 0.35 * sin(progress * M_PI * 4.0);
+ NSColor *pulseColor = [NSColor colorWithCalibratedRed:43.0 / 255.0 green:124.0 / 255.0 blue:132.0 / 255.0 alpha:pulse];
+ FillRounded(NSMakeRect(934, 204, 158, 48), 24, pulseColor);
+ DrawText(@"demo.mp4", 980, 218, 96, 24, 16, [NSColor whiteColor], YES);
+
+ DrawText(@"citation-context:5:6: deterministic audit packet", 88, 92, 520, 24, 16, Color(100, 92, 82), NO);
+
+ [NSGraphicsContext restoreGraphicsState];
+}
+
+static BOOL AppendFrame(AVAssetWriterInputPixelBufferAdaptor *adaptor, CMTime time, NSInteger frameIndex) {
+ CVPixelBufferRef pixelBuffer = NULL;
+ CVReturn result = CVPixelBufferPoolCreatePixelBuffer(NULL, adaptor.pixelBufferPool, &pixelBuffer);
+ if (result != kCVReturnSuccess || pixelBuffer == NULL) {
+ return NO;
+ }
+
+ CVPixelBufferLockBaseAddress(pixelBuffer, 0);
+ void *baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer);
+ size_t bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer);
+ CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
+ CGContextRef bitmapContext = CGBitmapContextCreate(baseAddress, FrameWidth, FrameHeight, 8, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Host);
+
+ DrawFrame(bitmapContext, frameIndex);
+
+ CGContextRelease(bitmapContext);
+ CGColorSpaceRelease(colorSpace);
+ CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
+
+ BOOL appended = [adaptor appendPixelBuffer:pixelBuffer withPresentationTime:time];
+ CVPixelBufferRelease(pixelBuffer);
+ return appended;
+}
+
+int main(int argc, const char *argv[]) {
+ @autoreleasepool {
+ if (argc < 2) {
+ fprintf(stderr, "usage: render-demo-video output.mp4\n");
+ return 1;
+ }
+
+ NSString *outputPath = [NSString stringWithUTF8String:argv[1]];
+ NSURL *outputURL = [NSURL fileURLWithPath:outputPath];
+ [[NSFileManager defaultManager] removeItemAtURL:outputURL error:nil];
+
+ NSError *error = nil;
+ AVAssetWriter *writer = [[AVAssetWriter alloc] initWithURL:outputURL fileType:AVFileTypeMPEG4 error:&error];
+ if (!writer) {
+ fprintf(stderr, "failed to create writer: %s\n", error.localizedDescription.UTF8String);
+ return 1;
+ }
+
+ NSDictionary *settings = @{
+ AVVideoCodecKey: AVVideoCodecTypeH264,
+ AVVideoWidthKey: @(FrameWidth),
+ AVVideoHeightKey: @(FrameHeight),
+ AVVideoCompressionPropertiesKey: @{
+ AVVideoAverageBitRateKey: @(1800000)
+ }
+ };
+ AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:settings];
+ input.expectsMediaDataInRealTime = NO;
+
+ NSDictionary *attributes = @{
+ (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
+ (NSString *)kCVPixelBufferWidthKey: @(FrameWidth),
+ (NSString *)kCVPixelBufferHeightKey: @(FrameHeight)
+ };
+ AVAssetWriterInputPixelBufferAdaptor *adaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:input sourcePixelBufferAttributes:attributes];
+
+ if (![writer canAddInput:input]) {
+ fprintf(stderr, "writer cannot add video input\n");
+ return 1;
+ }
+ [writer addInput:input];
+
+ [writer startWriting];
+ [writer startSessionAtSourceTime:kCMTimeZero];
+
+ for (NSInteger frame = 0; frame < TotalFrames; frame++) {
+ while (!input.readyForMoreMediaData) {
+ [NSThread sleepForTimeInterval:0.01];
+ }
+ CMTime time = CMTimeMake(frame, FramesPerSecond);
+ if (!AppendFrame(adaptor, time, frame)) {
+ fprintf(stderr, "failed to append frame %ld\n", (long)frame);
+ return 1;
+ }
+ }
+
+ [input markAsFinished];
+ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
+ [writer finishWritingWithCompletionHandler:^{
+ dispatch_semaphore_signal(semaphore);
+ }];
+ dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
+
+ if (writer.status != AVAssetWriterStatusCompleted) {
+ fprintf(stderr, "failed to write video: %s\n", writer.error.localizedDescription.UTF8String);
+ return 1;
+ }
+
+ printf("wrote %s\n", outputPath.UTF8String);
+ }
+ return 0;
+}
diff --git a/citation-context-reconciler/src/citation-context-reconciler.js b/citation-context-reconciler/src/citation-context-reconciler.js
new file mode 100644
index 0000000..d6a9b81
--- /dev/null
+++ b/citation-context-reconciler/src/citation-context-reconciler.js
@@ -0,0 +1,361 @@
+import { createHash } from "node:crypto";
+
+const CURRENT_YEAR = 2026;
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function unique(values) {
+ return [...new Set(values.filter(Boolean))];
+}
+
+function setOverlap(left = [], right = []) {
+ const rightSet = new Set(right);
+ return left.filter((item) => rightSet.has(item));
+}
+
+function stableStringify(value) {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableStringify).join(",")}]`;
+ }
+ if (value && typeof value === "object") {
+ return `{${Object.keys(value)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
+ .join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function digestFor(value) {
+ return createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16);
+}
+
+function finding(severity, code, claim, message, sourceIds = [], task = undefined) {
+ return {
+ severity,
+ code,
+ claimId: claim?.id ?? "project",
+ section: claim?.section ?? "project",
+ sourceIds: unique(sourceIds),
+ message,
+ task
+ };
+}
+
+function sourceLabel(source) {
+ return source ? `${source.id} (${source.year ?? "n.d."})` : "unknown source";
+}
+
+function buildCitationMatrix(claims, sourceById) {
+ return claims.map((claim) => {
+ const sources = asArray(claim.citedSourceIds).map((sourceId) => sourceById.get(sourceId)).filter(Boolean);
+ return {
+ claimId: claim.id,
+ section: claim.section,
+ citedSources: sources.map((source) => ({
+ id: source.id,
+ stance: source.stance ?? "unknown",
+ intent: source.citationIntent ?? "unspecified",
+ effectDirection: source.effectDirection ?? "unknown",
+ methodOverlap: setOverlap(asArray(claim.methodTags), asArray(source.methodTags)),
+ populationOverlap: setOverlap(asArray(claim.populations), asArray(source.populations)),
+ reproducibilityEvidence: {
+ rawData: Boolean(source.hasRawData),
+ code: Boolean(source.hasCode),
+ protocol: Boolean(source.hasProtocol)
+ }
+ }))
+ };
+ });
+}
+
+function scoreReproducibility(claims, sourceById) {
+ const scoredClaims = claims.map((claim) => {
+ const citedSources = asArray(claim.citedSourceIds).map((sourceId) => sourceById.get(sourceId)).filter(Boolean);
+ const evidencePoints = citedSources.reduce((points, source) => {
+ return points + Number(Boolean(source.hasRawData)) + Number(Boolean(source.hasCode)) + Number(Boolean(source.hasProtocol));
+ }, 0);
+ const maxPoints = Math.max(citedSources.length * 3, 1);
+ return {
+ claimId: claim.id,
+ score: Math.round((evidencePoints / maxPoints) * 100),
+ required: Boolean(claim.requiresReproducibilityEvidence)
+ };
+ });
+
+ const requiredClaims = scoredClaims.filter((claim) => claim.required);
+ const targetClaims = requiredClaims.length > 0 ? requiredClaims : scoredClaims;
+ const score = Math.round(
+ targetClaims.reduce((sum, claim) => sum + claim.score, 0) / Math.max(targetClaims.length, 1)
+ );
+
+ return {
+ score,
+ claims: scoredClaims,
+ confidence: score >= 85 ? "high" : score >= 60 ? "medium" : "low"
+ };
+}
+
+function buildOpportunityFeed(packet, findings) {
+ const blockerClaimIds = new Set(
+ findings.filter((item) => item.severity === "blocker").map((item) => item.claimId)
+ );
+ const labCapabilities = asArray(packet.lab?.capabilities);
+ const labInterests = asArray(packet.lab?.interests);
+
+ return asArray(packet.corpusGaps).map((gap) => {
+ const capabilityOverlap = setOverlap(asArray(gap.requiredCapabilities), labCapabilities);
+ const interestOverlap = setOverlap(asArray(gap.topicTags), labInterests);
+ const linkedBlockers = asArray(gap.linkedClaimIds).filter((claimId) => blockerClaimIds.has(claimId));
+ const priority =
+ capabilityOverlap.length * 3 +
+ interestOverlap.length * 2 +
+ linkedBlockers.length * 3 +
+ asArray(gap.contradictionSignals).length;
+
+ return {
+ id: gap.id,
+ title: gap.title,
+ priority,
+ capabilityOverlap,
+ interestOverlap,
+ linkedBlockers,
+ nextAction: gap.nextAction
+ };
+ }).sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
+}
+
+function reviewCommentFor(item) {
+ const prefix = item.severity === "blocker" ? "Resolve before release" : "Review before submission";
+ return {
+ claimId: item.claimId,
+ section: item.section,
+ severity: item.severity,
+ body: `${prefix}: ${item.message}`
+ };
+}
+
+function revisionTaskFor(item) {
+ return {
+ claimId: item.claimId,
+ code: item.code,
+ ownerHint: item.severity === "blocker" ? "author" : "reviewer",
+ task: item.task ?? item.message
+ };
+}
+
+export function reconcileCitationContext(packet, options = {}) {
+ const staleAfterYears = options.staleAfterYears ?? 7;
+ const claims = asArray(packet.claims);
+ const sources = asArray(packet.sources);
+ const sourceById = new Map(sources.map((source) => [source.id, source]));
+ const findings = [];
+
+ if (claims.length === 0) {
+ findings.push(finding("blocker", "no-claims", undefined, "No manuscript claims were provided for assistant review."));
+ }
+
+ for (const claim of claims) {
+ const citedSourceIds = asArray(claim.citedSourceIds);
+
+ if (citedSourceIds.length === 0) {
+ findings.push(finding(
+ "blocker",
+ "claim-without-citation",
+ claim,
+ "The claim has no linked citation context.",
+ [],
+ "Attach at least one source that directly supports or constrains this claim."
+ ));
+ continue;
+ }
+
+ const citedSources = [];
+ for (const sourceId of citedSourceIds) {
+ const source = sourceById.get(sourceId);
+ if (!source) {
+ findings.push(finding(
+ "blocker",
+ "unknown-source",
+ claim,
+ `The claim references missing source ${sourceId}.`,
+ [sourceId],
+ "Add the missing source metadata or remove the dangling citation."
+ ));
+ continue;
+ }
+ citedSources.push(source);
+ }
+
+ const citedEffectDirections = unique(citedSources.map((source) => source.effectDirection));
+ const nonNeutralDirections = citedEffectDirections.filter((direction) => direction && direction !== "mixed" && direction !== "unknown");
+ if (nonNeutralDirections.length > 1) {
+ findings.push(finding(
+ "blocker",
+ "contradictory-cited-effects",
+ claim,
+ `The cited sources disagree on effect direction: ${nonNeutralDirections.join(", ")}.`,
+ citedSources.map((source) => source.id),
+ "Rewrite the claim as contested or add an adjudication note explaining why one direction is preferred."
+ ));
+ }
+
+ for (const source of citedSources) {
+ const sourceIds = [source.id];
+ if (source.retracted) {
+ findings.push(finding(
+ "blocker",
+ "retracted-citation",
+ claim,
+ `${sourceLabel(source)} is marked retracted.`,
+ sourceIds,
+ "Remove or explicitly label the retracted source before release."
+ ));
+ }
+
+ if (source.stance === "contradicts") {
+ findings.push(finding(
+ "blocker",
+ "contradicting-source-used-as-support",
+ claim,
+ `${sourceLabel(source)} contradicts the claim but is cited without a limitation note.`,
+ sourceIds,
+ "Move the source into a limitation sentence or add a rebuttal-backed explanation."
+ ));
+ }
+
+ if (source.stance === "mixed") {
+ findings.push(finding(
+ "warning",
+ "mixed-source-needs-qualification",
+ claim,
+ `${sourceLabel(source)} reports mixed evidence and needs qualified language.`,
+ sourceIds,
+ "Add uncertainty language and identify which subgroup or assay is supported."
+ ));
+ }
+
+ if (claim.effectDirection && source.effectDirection && source.effectDirection !== "mixed" && claim.effectDirection !== source.effectDirection) {
+ findings.push(finding(
+ "blocker",
+ "effect-direction-mismatch",
+ claim,
+ `${sourceLabel(source)} has effect direction ${source.effectDirection}, but the claim states ${claim.effectDirection}.`,
+ sourceIds,
+ "Align the claim with the source or cite a direct source for the stated effect direction."
+ ));
+ }
+
+ if (["background", "context", "method"].includes(source.citationIntent) && claim.usesCitationAs === "evidence") {
+ findings.push(finding(
+ "blocker",
+ "citation-intent-mismatch",
+ claim,
+ `${sourceLabel(source)} is tagged for ${source.citationIntent}, not direct evidence.`,
+ sourceIds,
+ "Use the citation as context only or replace it with a direct empirical support source."
+ ));
+ }
+
+ const methodOverlap = setOverlap(asArray(claim.methodTags), asArray(source.methodTags));
+ if (asArray(claim.methodTags).length > 0 && methodOverlap.length === 0) {
+ findings.push(finding(
+ "warning",
+ "method-context-gap",
+ claim,
+ `${sourceLabel(source)} does not share the claim method tags: ${asArray(claim.methodTags).join(", ")}.`,
+ sourceIds,
+ "Add a same-method source or explain the cross-method inference."
+ ));
+ }
+
+ const populationOverlap = setOverlap(asArray(claim.populations), asArray(source.populations));
+ if (asArray(claim.populations).length > 0 && populationOverlap.length === 0) {
+ findings.push(finding(
+ "warning",
+ "population-context-gap",
+ claim,
+ `${sourceLabel(source)} does not cover the claim population: ${asArray(claim.populations).join(", ")}.`,
+ sourceIds,
+ "Add population-matched evidence or narrow the claim."
+ ));
+ }
+
+ if (source.year && CURRENT_YEAR - source.year > staleAfterYears && claim.requiresCurrentEvidence) {
+ findings.push(finding(
+ "warning",
+ "stale-citation",
+ claim,
+ `${sourceLabel(source)} is older than the configured ${staleAfterYears}-year recency window.`,
+ sourceIds,
+ "Run a recency search and add a current synthesis or explain why the older source is canonical."
+ ));
+ }
+
+ if (claim.requiresReproducibilityEvidence && (!source.hasRawData || !source.hasCode || !source.hasProtocol)) {
+ const missing = [
+ !source.hasRawData ? "raw data" : undefined,
+ !source.hasCode ? "code" : undefined,
+ !source.hasProtocol ? "protocol" : undefined
+ ].filter(Boolean);
+ findings.push(finding(
+ "warning",
+ "reproducibility-evidence-gap",
+ claim,
+ `${sourceLabel(source)} is missing reproducibility evidence: ${missing.join(", ")}.`,
+ sourceIds,
+ "Link the missing artifacts or lower the reproducibility confidence for this claim."
+ ));
+ }
+ }
+ }
+
+ const severityRank = { blocker: 0, warning: 1, info: 2 };
+ findings.sort((left, right) => {
+ return severityRank[left.severity] - severityRank[right.severity] ||
+ left.claimId.localeCompare(right.claimId) ||
+ left.code.localeCompare(right.code);
+ });
+
+ const citationMatrix = buildCitationMatrix(claims, sourceById);
+ const reproducibility = scoreReproducibility(claims, sourceById);
+ const opportunityFeed = buildOpportunityFeed(packet, findings);
+ const counts = {
+ claims: claims.length,
+ sources: sources.length,
+ blockers: findings.filter((item) => item.severity === "blocker").length,
+ warnings: findings.filter((item) => item.severity === "warning").length,
+ opportunities: opportunityFeed.length
+ };
+
+ const reviewerQueue = findings.map(reviewCommentFor);
+ const revisionTasks = findings.map(revisionTaskFor);
+ const exportPacket = {
+ projectId: packet.project?.id ?? "unknown-project",
+ generatedAt: packet.generatedAt ?? "2026-05-16T18:20:00.000Z",
+ citationMatrix,
+ reviewerQueue,
+ revisionTasks,
+ reproducibility,
+ opportunityFeed
+ };
+
+ const auditDigest = `citation-context:${counts.blockers}:${counts.warnings}:${digestFor(exportPacket)}`;
+
+ return {
+ ready: counts.blockers === 0,
+ counts,
+ findings,
+ reviewerQueue,
+ revisionTasks,
+ citationMatrix,
+ reproducibility,
+ opportunityFeed,
+ exportPacket: {
+ ...exportPacket,
+ auditDigest
+ }
+ };
+}
diff --git a/citation-context-reconciler/test/citation-context-reconciler.test.js b/citation-context-reconciler/test/citation-context-reconciler.test.js
new file mode 100644
index 0000000..42d2deb
--- /dev/null
+++ b/citation-context-reconciler/test/citation-context-reconciler.test.js
@@ -0,0 +1,66 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { reconcileCitationContext } from "../src/citation-context-reconciler.js";
+
+const fixture = JSON.parse(readFileSync(new URL("../sample/citation-context-packet.json", import.meta.url), "utf8"));
+
+const audit = reconcileCitationContext(fixture);
+assert.equal(audit.ready, false);
+assert.equal(audit.counts.claims, 3);
+assert.equal(audit.counts.sources, 4);
+assert.equal(audit.counts.blockers, 5);
+assert.equal(audit.counts.warnings, 6);
+assert.equal(audit.counts.opportunities, 2);
+assert.match(audit.exportPacket.auditDigest, /^citation-context:5:6:[a-f0-9]{16}$/);
+
+const blockerCodes = audit.findings
+ .filter((item) => item.severity === "blocker")
+ .map((item) => item.code)
+ .sort();
+assert.deepEqual(blockerCodes, [
+ "citation-intent-mismatch",
+ "citation-intent-mismatch",
+ "contradicting-source-used-as-support",
+ "contradictory-cited-effects",
+ "effect-direction-mismatch"
+]);
+
+assert.equal(audit.reproducibility.confidence, "low");
+assert.equal(audit.opportunityFeed[0].id, "gap-older-adult-replication");
+assert.equal(
+ audit.revisionTasks.some((task) => task.code === "citation-intent-mismatch" && task.ownerHint === "author"),
+ true
+);
+
+const cleanPacket = structuredClone(fixture);
+cleanPacket.claims = [
+ {
+ id: "claim-clean-cytokine",
+ section: "Results",
+ text: "A recent replication did not detect a cytokine decrease in older post-viral cohorts.",
+ citedSourceIds: ["src-recent-null-meta"],
+ methodTags: ["wearable-sensor-cohort", "cytokine-panel"],
+ populations: ["older-adults", "post-viral-fatigue"],
+ effectDirection: "no-effect",
+ usesCitationAs: "limitation",
+ requiresCurrentEvidence: true,
+ requiresReproducibilityEvidence: true
+ }
+];
+cleanPacket.sources = cleanPacket.sources.map((source) =>
+ source.id === "src-recent-null-meta" ? { ...source, stance: "supports" } : source
+);
+cleanPacket.corpusGaps = [];
+const cleanAudit = reconcileCitationContext(cleanPacket);
+assert.equal(cleanAudit.ready, true);
+assert.equal(cleanAudit.counts.blockers, 0);
+assert.equal(cleanAudit.counts.warnings, 0);
+assert.equal(cleanAudit.reproducibility.confidence, "high");
+
+const danglingPacket = structuredClone(cleanPacket);
+danglingPacket.claims[0].citedSourceIds = ["src-missing"];
+const danglingAudit = reconcileCitationContext(danglingPacket);
+assert.equal(danglingAudit.ready, false);
+assert.equal(danglingAudit.findings.some((item) => item.code === "unknown-source"), true);
+
+console.log("citation context reconciler tests passed");