diff --git a/assistant-decision-handoff-guard/README.md b/assistant-decision-handoff-guard/README.md new file mode 100644 index 00000000..3c51693a --- /dev/null +++ b/assistant-decision-handoff-guard/README.md @@ -0,0 +1,49 @@ +# Assistant Decision Handoff Guard + +This module adds a focused assistant decision handoff guard for the +AI-Powered Research Assistant Suite bounty. It checks whether outputs from +peer-review, reproducibility, and research-gap assistants are safe to turn into +researcher-facing recommendations or tasks. + +## Scope + +The guard evaluates synthetic assistant handoff packets for: + +- source snapshot freshness before recommendations are released +- artifact revision drift between the assistant context and current project +- cross-assistant conflicts from peer-review, reproducibility, and gap engines +- evidence anchors required by handoff type +- high-impact action authority and human approval state +- private reviewer note or identity leakage before user-facing release +- confidence and remediation coverage for actionability +- deterministic release, revise, or hold decisions with audit digests + +This is not another peer-review generator, reproducibility score, research-gap +finder, evidence binder, prompt-safety filter, structured abstract checker, or +external-validity assistant. It is the handoff boundary that decides whether +existing assistant outputs can safely become user-visible actions. + +## Issue #16 Requirement Mapping + +- Auto peer review reports: blocks private-note leakage, stale manuscript + context, unsupported reviewer-facing actions, and missing evidence anchors. +- Reproducibility checker: prevents badge or release handoffs when run logs, + environment locks, result digests, or human approvals are incomplete. +- Research gap finder: checks corpus freshness, novelty evidence, limitation + signals, and confidence before opportunities become researcher tasks. +- Suite orchestration: reconciles conflicts between assistant outputs before a + unified action is shown to the researcher. + +## Validation + +```bash +node assistant-decision-handoff-guard/test.js +node assistant-decision-handoff-guard/demo.js +node assistant-decision-handoff-guard/render-video.js +node --check assistant-decision-handoff-guard/index.js +node --check assistant-decision-handoff-guard/sample-data.js +node --check assistant-decision-handoff-guard/test.js +node --check assistant-decision-handoff-guard/demo.js +node --check assistant-decision-handoff-guard/render-video.js +git diff --check +``` diff --git a/assistant-decision-handoff-guard/demo.js b/assistant-decision-handoff-guard/demo.js new file mode 100644 index 00000000..38c633e7 --- /dev/null +++ b/assistant-decision-handoff-guard/demo.js @@ -0,0 +1,27 @@ +const fs = require("fs"); +const path = require("path"); +const packets = require("./sample-data"); +const { + evaluateHandoffBatch, + renderMarkdownReport, + renderSvgReport, +} = require("./index"); + +const reportDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportDir, { recursive: true }); + +const report = evaluateHandoffBatch(packets); +const jsonPath = path.join(reportDir, "assistant-handoff-review.json"); +const markdownPath = path.join(reportDir, "assistant-handoff-review.md"); +const svgPath = path.join(reportDir, "assistant-handoff-review.svg"); + +fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`); +fs.writeFileSync(markdownPath, renderMarkdownReport(report)); +fs.writeFileSync(svgPath, renderSvgReport(report)); + +console.log(`wrote ${path.relative(process.cwd(), jsonPath)}`); +console.log(`wrote ${path.relative(process.cwd(), markdownPath)}`); +console.log(`wrote ${path.relative(process.cwd(), svgPath)}`); +console.log( + `summary: ${report.summary.release} release, ${report.summary.revise} revise, ${report.summary.hold} hold` +); diff --git a/assistant-decision-handoff-guard/index.js b/assistant-decision-handoff-guard/index.js new file mode 100644 index 00000000..026a8c36 --- /dev/null +++ b/assistant-decision-handoff-guard/index.js @@ -0,0 +1,287 @@ +const crypto = require("crypto"); + +const GENERATED_AT = "2026-05-31T00:00:00Z"; + +function requiredEvidenceFor(packet) { + const byType = { + peer_review_comment: ["manuscript-section", "evidence-snippet"], + reproducibility_release: ["run-log", "environment-lock", "result-digest"], + research_gap_opportunity: ["corpus-snapshot", "limitation-signal", "novelty-check"], + }; + + return byType[packet.outputType] || ["source-artifact", "evidence-snippet"]; +} + +function maxAgeDaysFor(packet) { + const byType = { + peer_review_comment: 14, + reproducibility_release: 7, + research_gap_opportunity: 30, + }; + + return byType[packet.outputType] || 14; +} + +function daysOld(packet, now = GENERATED_AT) { + const captured = Date.parse(packet.snapshotCapturedAt); + const current = Date.parse(now); + if (!Number.isFinite(captured) || !Number.isFinite(current)) { + return Number.POSITIVE_INFINITY; + } + return Math.max(0, Math.floor((current - captured) / (24 * 60 * 60 * 1000))); +} + +function missingEvidence(packet) { + const present = new Set(packet.evidenceAnchors || []); + return requiredEvidenceFor(packet).filter((item) => !present.has(item)); +} + +function conflictSignals(packet) { + return (packet.upstreamSignals || []).filter((signal) => + ["block", "contradiction"].includes(signal.status) + ); +} + +function needsHumanApproval(packet) { + return packet.autoExecute || packet.impact === "high" || packet.outputType === "reproducibility_release"; +} + +function evaluateHandoff(packet, now = GENERATED_AT) { + const blockers = []; + const warnings = []; + const actions = []; + const missing = missingEvidence(packet); + const conflicts = conflictSignals(packet); + const age = daysOld(packet, now); + const maxAge = maxAgeDaysFor(packet); + + if (packet.snapshotRevision !== packet.targetRevision) { + blockers.push( + `artifact revision drift: assistant used ${packet.snapshotRevision}, current target is ${packet.targetRevision}` + ); + actions.push("refresh assistant context against the current artifact revision"); + } + + if (age > maxAge) { + blockers.push(`snapshot is ${age} days old, above ${maxAge} day limit`); + actions.push("refresh source snapshot before handoff"); + } + + if (missing.length > 0) { + blockers.push(`missing evidence anchors: ${missing.join(", ")}`); + actions.push("attach required evidence anchors before release"); + } + + for (const signal of conflicts) { + blockers.push(`${signal.assistant} reported ${signal.status}: ${signal.detail}`); + actions.push("resolve cross-assistant conflict before user-facing handoff"); + } + + if (needsHumanApproval(packet) && packet.humanApproval !== "approved") { + blockers.push(`human approval is ${packet.humanApproval}`); + actions.push("obtain responsible human approval for high-impact handoff"); + } + + if (packet.containsPrivateNotes) { + blockers.push("private reviewer notes are present"); + actions.push("redact private reviewer notes before author-visible release"); + } + + if (packet.reviewerIdentityRisk) { + blockers.push("reviewer identity leakage risk detected"); + actions.push("remove identity-bearing details from the handoff"); + } + + if (packet.confidence < 0.55) { + blockers.push(`assistant confidence ${packet.confidence} is below release floor`); + actions.push("rerun or demote the assistant output"); + } else if (packet.confidence < 0.7) { + warnings.push(`assistant confidence ${packet.confidence} needs strengthening`); + actions.push("keep the output in draft until more support is attached"); + } + + if ((packet.remediationSteps || []).length === 0) { + warnings.push("no remediation steps provided"); + actions.push("add concrete next steps before handoff"); + } + + if (blockers.length === 0 && warnings.length === 0) { + actions.push("release handoff to researcher-facing queue"); + } else if (blockers.length === 0) { + actions.push("revise handoff before promotion"); + } + + const decision = blockers.length > 0 ? "hold" : warnings.length > 0 ? "revise" : "release"; + const result = { + handoffId: packet.id, + assistant: packet.assistant, + outputType: packet.outputType, + title: packet.title, + targetArtifact: packet.targetArtifact, + targetRevision: packet.targetRevision, + snapshotRevision: packet.snapshotRevision, + snapshotAgeDays: age, + impact: packet.impact, + confidence: packet.confidence, + decision, + blockers, + warnings, + actions: [...new Set(actions)], + }; + + return { + ...result, + auditDigest: digest(result), + }; +} + +function evaluateHandoffBatch(packets, now = GENERATED_AT) { + const decisions = packets.map((packet) => evaluateHandoff(packet, now)); + const summary = decisions.reduce( + (acc, item) => { + acc.total += 1; + acc[item.decision] += 1; + acc.blockers += item.blockers.length; + acc.warnings += item.warnings.length; + if (item.snapshotAgeDays > acc.maxSnapshotAgeDays) { + acc.maxSnapshotAgeDays = item.snapshotAgeDays; + } + return acc; + }, + { + total: 0, + release: 0, + revise: 0, + hold: 0, + blockers: 0, + warnings: 0, + maxSnapshotAgeDays: 0, + } + ); + + const report = { + generatedAt: new Date(now).toISOString(), + summary, + decisions, + }; + + return { + ...report, + auditDigest: digest(report), + }; +} + +function renderMarkdownReport(report) { + const lines = [ + "# Assistant Decision Handoff Guard Report", + "", + `Generated: ${report.generatedAt}`, + `Audit digest: ${report.auditDigest}`, + "", + "## Summary", + "", + `- Handoff packets: ${report.summary.total}`, + `- Release: ${report.summary.release}`, + `- Revise: ${report.summary.revise}`, + `- Hold: ${report.summary.hold}`, + `- Blockers: ${report.summary.blockers}`, + `- Warnings: ${report.summary.warnings}`, + `- Max snapshot age days: ${report.summary.maxSnapshotAgeDays}`, + "", + "## Decisions", + "", + ]; + + for (const item of report.decisions) { + lines.push(`### ${item.handoffId}`); + lines.push(""); + lines.push(`- Assistant: ${item.assistant}`); + lines.push(`- Output type: ${item.outputType}`); + lines.push(`- Decision: ${item.decision}`); + lines.push(`- Target: ${item.targetArtifact} @ ${item.targetRevision}`); + lines.push(`- Snapshot revision: ${item.snapshotRevision}`); + lines.push(`- Snapshot age days: ${item.snapshotAgeDays}`); + lines.push(`- Confidence: ${item.confidence}`); + lines.push(`- Blockers: ${item.blockers.join("; ") || "none"}`); + lines.push(`- Warnings: ${item.warnings.join("; ") || "none"}`); + lines.push(`- Actions: ${item.actions.join("; ")}`); + lines.push(`- Digest: ${item.auditDigest}`); + lines.push(""); + } + + while (lines[lines.length - 1] === "") { + lines.pop(); + } + + return `${lines.join("\n")}\n`; +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderSvgReport(report) { + const cards = [ + ["Packets", report.summary.total, "#264653"], + ["Release", report.summary.release, "#2a9d8f"], + ["Revise", report.summary.revise, "#e9c46a"], + ["Hold", report.summary.hold, "#e76f51"], + ["Blockers", report.summary.blockers, "#8d3b72"], + ["Max age", `${report.summary.maxSnapshotAgeDays}d`, "#457b9d"], + ]; + + const cardSvg = cards + .map(([label, value, color], index) => { + const x = 70 + (index % 3) * 382; + const y = 184 + Math.floor(index / 3) * 148; + return [ + ``, + ``, + `${escapeXml(label)}`, + `${escapeXml(value)}`, + ].join("\n"); + }) + .join("\n"); + + const queueRows = report.decisions + .filter((item) => item.decision !== "release") + .slice(0, 4) + .map((item, index) => { + const y = 528 + index * 31; + return `${escapeXml(item.handoffId)} - ${escapeXml(item.decision)} - ${escapeXml(item.actions[0])}`; + }) + .join("\n"); + + return ` + + + Assistant Decision Handoff Guard + Safe promotion checks for AI assistant outputs + ${cardSvg} + Held or revision queue + ${queueRows} + Audit digest ${escapeXml(report.auditDigest.slice(0, 32))} +`; +} + +function digest(value) { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +module.exports = { + GENERATED_AT, + conflictSignals, + daysOld, + digest, + evaluateHandoff, + evaluateHandoffBatch, + missingEvidence, + needsHumanApproval, + renderMarkdownReport, + renderSvgReport, + requiredEvidenceFor, +}; diff --git a/assistant-decision-handoff-guard/render-video.js b/assistant-decision-handoff-guard/render-video.js new file mode 100644 index 00000000..c08eb03b --- /dev/null +++ b/assistant-decision-handoff-guard/render-video.js @@ -0,0 +1,50 @@ +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const reportDir = path.join(__dirname, "reports"); +const jsonPath = path.join(reportDir, "assistant-handoff-review.json"); +const svgPath = path.join(reportDir, "assistant-handoff-review.svg"); +const framePath = path.join(reportDir, "assistant-handoff-review.png"); +const outputPath = path.join(reportDir, "demo.mp4"); + +if (!fs.existsSync(jsonPath) || !fs.existsSync(svgPath)) { + require("./demo"); +} + +const report = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + +execFileSync( + "rsvg-convert", + ["--width", "1280", "--height", "720", "--output", framePath, svgPath], + { stdio: "inherit" } +); + +execFileSync( + "ffmpeg", + [ + "-y", + "-loop", + "1", + "-framerate", + "25", + "-i", + framePath, + "-t", + "4", + "-pix_fmt", + "yuv420p", + "-vf", + "scale=1280:720", + "-movflags", + "+faststart", + outputPath, + ], + { stdio: "inherit" } +); + +fs.unlinkSync(framePath); + +console.log( + `wrote ${path.relative(process.cwd(), outputPath)} for ${report.summary.total} handoff packets` +); diff --git a/assistant-decision-handoff-guard/reports/assistant-handoff-review.json b/assistant-decision-handoff-guard/reports/assistant-handoff-review.json new file mode 100644 index 00000000..6db87738 --- /dev/null +++ b/assistant-decision-handoff-guard/reports/assistant-handoff-review.json @@ -0,0 +1,131 @@ +{ + "generatedAt": "2026-05-31T00:00:00.000Z", + "summary": { + "total": 5, + "release": 1, + "revise": 1, + "hold": 3, + "blockers": 9, + "warnings": 1, + "maxSnapshotAgeDays": 50 + }, + "decisions": [ + { + "handoffId": "handoff:peer-review-methods-clarity", + "assistant": "auto-peer-review", + "outputType": "peer_review_comment", + "title": "Methods section clarity request", + "targetArtifact": "manuscript.md", + "targetRevision": "ms-v12", + "snapshotRevision": "ms-v12", + "snapshotAgeDays": 1, + "impact": "medium", + "confidence": 0.82, + "decision": "release", + "blockers": [], + "warnings": [], + "actions": [ + "release handoff to researcher-facing queue" + ], + "auditDigest": "421868aaa57b79f52841dbf0504c34e679406471ab77d6dd856799548199bb36" + }, + { + "handoffId": "handoff:reproducibility-badge-release", + "assistant": "reproducibility-checker", + "outputType": "reproducibility_release", + "title": "Publish reproducibility badge", + "targetArtifact": "analysis/notebook.ipynb", + "targetRevision": "analysis-v8", + "snapshotRevision": "analysis-v8", + "snapshotAgeDays": 0, + "impact": "high", + "confidence": 0.91, + "decision": "hold", + "blockers": [ + "missing evidence anchors: environment-lock", + "notebook-runner reported block: cell 18 remains nondeterministic", + "human approval is pending" + ], + "warnings": [], + "actions": [ + "attach required evidence anchors before release", + "resolve cross-assistant conflict before user-facing handoff", + "obtain responsible human approval for high-impact handoff" + ], + "auditDigest": "574cb0e6776fee74eff6f72e05cfc152078a2b5ed932214200125423b8cc75ef" + }, + { + "handoffId": "handoff:gap-opportunity-corpus-drift", + "assistant": "research-gap-finder", + "outputType": "research_gap_opportunity", + "title": "Suggest CRISPR organoid replication gap", + "targetArtifact": "gap-feed.json", + "targetRevision": "corpus-2026-05", + "snapshotRevision": "corpus-2026-04", + "snapshotAgeDays": 50, + "impact": "medium", + "confidence": 0.76, + "decision": "hold", + "blockers": [ + "artifact revision drift: assistant used corpus-2026-04, current target is corpus-2026-05", + "snapshot is 50 days old, above 30 day limit", + "missing evidence anchors: novelty-check" + ], + "warnings": [], + "actions": [ + "refresh assistant context against the current artifact revision", + "refresh source snapshot before handoff", + "attach required evidence anchors before release" + ], + "auditDigest": "ef7b59e2be719c3f573cf059171dfbcf8d4496c521f75ef3f7363d2e844166ac" + }, + { + "handoffId": "handoff:gap-opportunity-low-confidence", + "assistant": "research-gap-finder", + "outputType": "research_gap_opportunity", + "title": "Suggest weakly supported imaging opportunity", + "targetArtifact": "gap-feed.json", + "targetRevision": "corpus-2026-05", + "snapshotRevision": "corpus-2026-05", + "snapshotAgeDays": 6, + "impact": "low", + "confidence": 0.64, + "decision": "revise", + "blockers": [], + "warnings": [ + "assistant confidence 0.64 needs strengthening" + ], + "actions": [ + "keep the output in draft until more support is attached", + "revise handoff before promotion" + ], + "auditDigest": "e8b49c282d105781c1287a607ecb7bda0a84245d219fe67567ef562637e91850" + }, + { + "handoffId": "handoff:peer-review-private-note", + "assistant": "auto-peer-review", + "outputType": "peer_review_comment", + "title": "Reviewer concern copied from private note", + "targetArtifact": "manuscript.md", + "targetRevision": "ms-v12", + "snapshotRevision": "ms-v12", + "snapshotAgeDays": 0, + "impact": "high", + "confidence": 0.88, + "decision": "hold", + "blockers": [ + "review-redaction reported block: private reviewer rationale was copied", + "private reviewer notes are present", + "reviewer identity leakage risk detected" + ], + "warnings": [], + "actions": [ + "resolve cross-assistant conflict before user-facing handoff", + "redact private reviewer notes before author-visible release", + "remove identity-bearing details from the handoff" + ], + "auditDigest": "c5ad64bf782f11ceae2da3bb73bea57bb753df660acdea82b341d4b2ae2d15d9" + } + ], + "auditDigest": "57569e4993e08c385bc9dcf05b6bf58625f7449b8d7cda2d519acfa3a7e4366a" +} diff --git a/assistant-decision-handoff-guard/reports/assistant-handoff-review.md b/assistant-decision-handoff-guard/reports/assistant-handoff-review.md new file mode 100644 index 00000000..62ae454a --- /dev/null +++ b/assistant-decision-handoff-guard/reports/assistant-handoff-review.md @@ -0,0 +1,86 @@ +# Assistant Decision Handoff Guard Report + +Generated: 2026-05-31T00:00:00.000Z +Audit digest: 57569e4993e08c385bc9dcf05b6bf58625f7449b8d7cda2d519acfa3a7e4366a + +## Summary + +- Handoff packets: 5 +- Release: 1 +- Revise: 1 +- Hold: 3 +- Blockers: 9 +- Warnings: 1 +- Max snapshot age days: 50 + +## Decisions + +### handoff:peer-review-methods-clarity + +- Assistant: auto-peer-review +- Output type: peer_review_comment +- Decision: release +- Target: manuscript.md @ ms-v12 +- Snapshot revision: ms-v12 +- Snapshot age days: 1 +- Confidence: 0.82 +- Blockers: none +- Warnings: none +- Actions: release handoff to researcher-facing queue +- Digest: 421868aaa57b79f52841dbf0504c34e679406471ab77d6dd856799548199bb36 + +### handoff:reproducibility-badge-release + +- Assistant: reproducibility-checker +- Output type: reproducibility_release +- Decision: hold +- Target: analysis/notebook.ipynb @ analysis-v8 +- Snapshot revision: analysis-v8 +- Snapshot age days: 0 +- Confidence: 0.91 +- Blockers: missing evidence anchors: environment-lock; notebook-runner reported block: cell 18 remains nondeterministic; human approval is pending +- Warnings: none +- Actions: attach required evidence anchors before release; resolve cross-assistant conflict before user-facing handoff; obtain responsible human approval for high-impact handoff +- Digest: 574cb0e6776fee74eff6f72e05cfc152078a2b5ed932214200125423b8cc75ef + +### handoff:gap-opportunity-corpus-drift + +- Assistant: research-gap-finder +- Output type: research_gap_opportunity +- Decision: hold +- Target: gap-feed.json @ corpus-2026-05 +- Snapshot revision: corpus-2026-04 +- Snapshot age days: 50 +- Confidence: 0.76 +- Blockers: artifact revision drift: assistant used corpus-2026-04, current target is corpus-2026-05; snapshot is 50 days old, above 30 day limit; missing evidence anchors: novelty-check +- Warnings: none +- Actions: refresh assistant context against the current artifact revision; refresh source snapshot before handoff; attach required evidence anchors before release +- Digest: ef7b59e2be719c3f573cf059171dfbcf8d4496c521f75ef3f7363d2e844166ac + +### handoff:gap-opportunity-low-confidence + +- Assistant: research-gap-finder +- Output type: research_gap_opportunity +- Decision: revise +- Target: gap-feed.json @ corpus-2026-05 +- Snapshot revision: corpus-2026-05 +- Snapshot age days: 6 +- Confidence: 0.64 +- Blockers: none +- Warnings: assistant confidence 0.64 needs strengthening +- Actions: keep the output in draft until more support is attached; revise handoff before promotion +- Digest: e8b49c282d105781c1287a607ecb7bda0a84245d219fe67567ef562637e91850 + +### handoff:peer-review-private-note + +- Assistant: auto-peer-review +- Output type: peer_review_comment +- Decision: hold +- Target: manuscript.md @ ms-v12 +- Snapshot revision: ms-v12 +- Snapshot age days: 0 +- Confidence: 0.88 +- Blockers: review-redaction reported block: private reviewer rationale was copied; private reviewer notes are present; reviewer identity leakage risk detected +- Warnings: none +- Actions: resolve cross-assistant conflict before user-facing handoff; redact private reviewer notes before author-visible release; remove identity-bearing details from the handoff +- Digest: c5ad64bf782f11ceae2da3bb73bea57bb753df660acdea82b341d4b2ae2d15d9 diff --git a/assistant-decision-handoff-guard/reports/assistant-handoff-review.svg b/assistant-decision-handoff-guard/reports/assistant-handoff-review.svg new file mode 100644 index 00000000..7ce7acfb --- /dev/null +++ b/assistant-decision-handoff-guard/reports/assistant-handoff-review.svg @@ -0,0 +1,36 @@ + + + + Assistant Decision Handoff Guard + Safe promotion checks for AI assistant outputs + + +Packets +5 + + +Release +1 + + +Revise +1 + + +Hold +3 + + +Blockers +9 + + +Max age +50d + Held or revision queue + handoff:reproducibility-badge-release - hold - attach required evidence anchors before release +handoff:gap-opportunity-corpus-drift - hold - refresh assistant context against the current artifact revision +handoff:gap-opportunity-low-confidence - revise - keep the output in draft until more support is attached +handoff:peer-review-private-note - hold - resolve cross-assistant conflict before user-facing handoff + Audit digest 57569e4993e08c385bc9dcf05b6bf586 + \ No newline at end of file diff --git a/assistant-decision-handoff-guard/reports/demo.mp4 b/assistant-decision-handoff-guard/reports/demo.mp4 new file mode 100644 index 00000000..36289723 Binary files /dev/null and b/assistant-decision-handoff-guard/reports/demo.mp4 differ diff --git a/assistant-decision-handoff-guard/sample-data.js b/assistant-decision-handoff-guard/sample-data.js new file mode 100644 index 00000000..a6b85013 --- /dev/null +++ b/assistant-decision-handoff-guard/sample-data.js @@ -0,0 +1,114 @@ +const handoffPackets = [ + { + id: "handoff:peer-review-methods-clarity", + assistant: "auto-peer-review", + outputType: "peer_review_comment", + title: "Methods section clarity request", + targetArtifact: "manuscript.md", + targetRevision: "ms-v12", + snapshotRevision: "ms-v12", + snapshotCapturedAt: "2026-05-29T09:00:00Z", + proposedAction: "show author a methods clarification task", + impact: "medium", + confidence: 0.82, + evidenceAnchors: ["manuscript-section", "evidence-snippet"], + upstreamSignals: [ + { assistant: "claim-evidence-checker", status: "pass", detail: "claim maps to methods paragraph 4" }, + ], + remediationSteps: ["ask author to define reagent lot policy", "link methods paragraph 4"], + autoExecute: false, + humanApproval: "not_required", + containsPrivateNotes: false, + reviewerIdentityRisk: false, + }, + { + id: "handoff:reproducibility-badge-release", + assistant: "reproducibility-checker", + outputType: "reproducibility_release", + title: "Publish reproducibility badge", + targetArtifact: "analysis/notebook.ipynb", + targetRevision: "analysis-v8", + snapshotRevision: "analysis-v8", + snapshotCapturedAt: "2026-05-30T11:20:00Z", + proposedAction: "publish reproducibility badge automatically", + impact: "high", + confidence: 0.91, + evidenceAnchors: ["run-log", "result-digest"], + upstreamSignals: [ + { assistant: "notebook-runner", status: "block", detail: "cell 18 remains nondeterministic" }, + ], + remediationSteps: ["rerun notebook with locked random seed", "attach environment lockfile"], + autoExecute: true, + humanApproval: "pending", + containsPrivateNotes: false, + reviewerIdentityRisk: false, + }, + { + id: "handoff:gap-opportunity-corpus-drift", + assistant: "research-gap-finder", + outputType: "research_gap_opportunity", + title: "Suggest CRISPR organoid replication gap", + targetArtifact: "gap-feed.json", + targetRevision: "corpus-2026-05", + snapshotRevision: "corpus-2026-04", + snapshotCapturedAt: "2026-04-10T12:00:00Z", + proposedAction: "create a thesis opportunity card", + impact: "medium", + confidence: 0.76, + evidenceAnchors: ["corpus-snapshot", "limitation-signal"], + upstreamSignals: [ + { assistant: "literature-refresh", status: "warn", detail: "new review article landed after snapshot" }, + ], + remediationSteps: ["refresh corpus snapshot", "rerun novelty check"], + autoExecute: false, + humanApproval: "not_required", + containsPrivateNotes: false, + reviewerIdentityRisk: false, + }, + { + id: "handoff:gap-opportunity-low-confidence", + assistant: "research-gap-finder", + outputType: "research_gap_opportunity", + title: "Suggest weakly supported imaging opportunity", + targetArtifact: "gap-feed.json", + targetRevision: "corpus-2026-05", + snapshotRevision: "corpus-2026-05", + snapshotCapturedAt: "2026-05-24T08:00:00Z", + proposedAction: "show as a draft opportunity card", + impact: "low", + confidence: 0.64, + evidenceAnchors: ["corpus-snapshot", "limitation-signal", "novelty-check"], + upstreamSignals: [ + { assistant: "citation-freshness", status: "pass", detail: "no retraction or replacement found" }, + ], + remediationSteps: ["add two more supporting papers before promoting"], + autoExecute: false, + humanApproval: "not_required", + containsPrivateNotes: false, + reviewerIdentityRisk: false, + }, + { + id: "handoff:peer-review-private-note", + assistant: "auto-peer-review", + outputType: "peer_review_comment", + title: "Reviewer concern copied from private note", + targetArtifact: "manuscript.md", + targetRevision: "ms-v12", + snapshotRevision: "ms-v12", + snapshotCapturedAt: "2026-05-30T16:00:00Z", + proposedAction: "show reviewer concern to author", + impact: "high", + confidence: 0.88, + evidenceAnchors: ["manuscript-section", "evidence-snippet"], + upstreamSignals: [ + { assistant: "review-redaction", status: "block", detail: "private reviewer rationale was copied" }, + ], + remediationSteps: ["redact private note", "rewrite concern using manuscript evidence only"], + autoExecute: false, + humanApproval: "approved", + containsPrivateNotes: true, + reviewerIdentityRisk: true, + }, +]; + +module.exports = handoffPackets; diff --git a/assistant-decision-handoff-guard/test.js b/assistant-decision-handoff-guard/test.js new file mode 100644 index 00000000..3c31636b --- /dev/null +++ b/assistant-decision-handoff-guard/test.js @@ -0,0 +1,90 @@ +const assert = require("assert"); +const packets = require("./sample-data"); +const { + GENERATED_AT, + conflictSignals, + daysOld, + evaluateHandoff, + evaluateHandoffBatch, + missingEvidence, + needsHumanApproval, + requiredEvidenceFor, +} = require("./index"); + +const report = evaluateHandoffBatch(packets); + +function decision(id) { + return report.decisions.find((item) => item.handoffId === id); +} + +assert.strictEqual(report.generatedAt, "2026-05-31T00:00:00.000Z"); +assert.strictEqual(report.summary.total, 5); +assert.strictEqual(report.summary.release, 1); +assert.strictEqual(report.summary.revise, 1); +assert.strictEqual(report.summary.hold, 3); +assert.strictEqual(report.summary.blockers, 9); +assert.strictEqual(report.summary.warnings, 1); +assert.strictEqual(report.summary.maxSnapshotAgeDays, 50); + +const cleanPeerReview = decision("handoff:peer-review-methods-clarity"); +assert.strictEqual(cleanPeerReview.decision, "release"); +assert.deepStrictEqual(cleanPeerReview.blockers, []); +assert(cleanPeerReview.actions.includes("release handoff to researcher-facing queue")); + +const reproducibility = decision("handoff:reproducibility-badge-release"); +assert.strictEqual(reproducibility.decision, "hold"); +assert( + reproducibility.blockers.some((item) => item.includes("environment-lock")), + "reproducibility handoff must require environment lock evidence" +); +assert( + reproducibility.blockers.some((item) => item.includes("notebook-runner")), + "reproducibility handoff should stop on upstream runner blocks" +); +assert( + reproducibility.blockers.some((item) => item.includes("human approval")), + "auto-publishing a badge should require human approval" +); + +const staleGap = decision("handoff:gap-opportunity-corpus-drift"); +assert.strictEqual(staleGap.decision, "hold"); +assert( + staleGap.blockers.some((item) => item.includes("artifact revision drift")), + "gap opportunity should detect corpus revision drift" +); +assert( + staleGap.blockers.some((item) => item.includes("snapshot is 50 days old")), + "gap opportunity should detect stale corpus context" +); +assert( + staleGap.blockers.some((item) => item.includes("novelty-check")), + "gap opportunity should require novelty evidence" +); + +const draftGap = decision("handoff:gap-opportunity-low-confidence"); +assert.strictEqual(draftGap.decision, "revise"); +assert( + draftGap.warnings.some((item) => item.includes("needs strengthening")), + "low-confidence opportunity should stay in draft revision" +); + +const privateNote = decision("handoff:peer-review-private-note"); +assert.strictEqual(privateNote.decision, "hold"); +assert( + privateNote.blockers.some((item) => item.includes("private reviewer notes")), + "private reviewer notes should block author-facing release" +); +assert( + privateNote.blockers.some((item) => item.includes("identity leakage")), + "reviewer identity leakage should block author-facing release" +); + +assert.deepStrictEqual(requiredEvidenceFor(packets[0]), ["manuscript-section", "evidence-snippet"]); +assert.strictEqual(daysOld(packets[0], GENERATED_AT), 1); +assert.strictEqual(missingEvidence(packets[1]).includes("environment-lock"), true); +assert.strictEqual(needsHumanApproval(packets[1]), true); +assert.strictEqual(conflictSignals(packets[1]).length, 1); +assert.match(report.auditDigest, /^[a-f0-9]{64}$/); +assert.match(evaluateHandoff(packets[0]).auditDigest, /^[a-f0-9]{64}$/); + +console.log("assistant decision handoff guard tests passed");