diff --git a/collaborative-discussion-sidebar-audit/README.md b/collaborative-discussion-sidebar-audit/README.md new file mode 100644 index 0000000..01c7f20 --- /dev/null +++ b/collaborative-discussion-sidebar-audit/README.md @@ -0,0 +1,26 @@ +# Collaborative Discussion Sidebar Audit + +This package adds a focused readiness layer for the real-time collaborative research editor requested in SCIBASE issue #12. It does not implement a broad editor shell, notebook runtime, reference formatter, lock recovery system, or figure/table workflow. Instead, it covers the issue requirement for a document chat or discussion sidebar per file or section. + +The audit model checks whether sidebar discussions are safe to export during an active manuscript review: + +- section and file scoped discussion threads +- open blockers, stale conversations, and unresolved reviewer tasks +- pinned sources and citation evidence for decisions +- conflicting accepted decisions on the same section/topic +- locked-section owner and reviewer participation +- deterministic handoff packets for review, export, and version history + +## Run + +```sh +npm test +npm run demo +npm run demo:video +``` + +`npm run demo` writes a JSON audit report and an SVG storyboard to `docs/`. `npm run demo:video` renders a short MP4 demo for reviewers. + +## Why This Slice Matters + +In a collaborative scientific editor, the sidebar is not just chat. It becomes the record of why a section changed, which source supports the decision, who still needs to respond, and what must be preserved when the document is exported or reviewed later. This package gives that workflow deterministic checks and a compact export packet before the larger editor UI is wired in. diff --git a/collaborative-discussion-sidebar-audit/docs/demo.mp4 b/collaborative-discussion-sidebar-audit/docs/demo.mp4 new file mode 100644 index 0000000..17d8573 Binary files /dev/null and b/collaborative-discussion-sidebar-audit/docs/demo.mp4 differ diff --git a/collaborative-discussion-sidebar-audit/docs/demo.svg b/collaborative-discussion-sidebar-audit/docs/demo.svg new file mode 100644 index 0000000..276959a --- /dev/null +++ b/collaborative-discussion-sidebar-audit/docs/demo.svg @@ -0,0 +1,29 @@ + + Collaborative discussion sidebar audit demo + A storyboard showing section-scoped discussion coverage, blockers, warnings, and export packet digest. + + + Discussion Sidebar Audit + Section-scoped threads, pinned evidence, decisions, and reviewer handoff packet + + Coverage + 3/3 sections + Sidebar threads are attached to manuscript sec + + Blockers + 5 + open-blocker / stale-thread + + Warnings + 3 + locked-section-missing-review-role / locked-se + + Tasks + 1 + Resolve blocker: Resolve assay exclusion crite + + Export packet + scibase-paper-alpha:5:3:1:ebc3395f938f + Ready: no | Pinned sources: 3 | Resolved threads: 2/4 + Generated by npm run demo for SCIBASE issue #12 review. + diff --git a/collaborative-discussion-sidebar-audit/docs/discussion-sidebar-audit-report.json b/collaborative-discussion-sidebar-audit/docs/discussion-sidebar-audit-report.json new file mode 100644 index 0000000..a86e5ba --- /dev/null +++ b/collaborative-discussion-sidebar-audit/docs/discussion-sidebar-audit-report.json @@ -0,0 +1,196 @@ +{ + "ready": false, + "project": { + "id": "scibase-paper-alpha", + "title": "Multimodal biomarker screening draft", + "reviewRound": "methods-freeze" + }, + "counts": { + "sections": 3, + "threads": 4, + "decisions": 3, + "blockers": 5, + "warnings": 3, + "reviewerTasks": 1 + }, + "sidebarCoverage": { + "scopedSections": 3, + "totalSections": 3, + "resolvedThreads": 2, + "totalThreads": 4, + "pinnedSources": 3, + "unscopedThreads": 1 + }, + "findings": [ + { + "severity": "blocker", + "code": "open-blocker", + "message": "Thread thr-methods-blocker is marked blocking and is still open.", + "context": { + "threadId": "thr-methods-blocker" + } + }, + { + "severity": "blocker", + "code": "stale-thread", + "message": "Thread thr-methods-blocker has been idle for 8 days.", + "context": { + "threadId": "thr-methods-blocker", + "staleDays": 8 + } + }, + { + "severity": "warning", + "code": "locked-section-missing-review-role", + "message": "Thread thr-methods-blocker on locked section sec-methods is missing reviewer participation.", + "context": { + "threadId": "thr-methods-blocker", + "sectionId": "sec-methods", + "missingRoles": [ + "reviewer" + ] + } + }, + { + "severity": "warning", + "code": "locked-section-missing-review-role", + "message": "Thread thr-results-decision on locked section sec-results is missing owner participation.", + "context": { + "threadId": "thr-results-decision", + "sectionId": "sec-results", + "missingRoles": [ + "owner" + ] + } + }, + { + "severity": "blocker", + "code": "unsafe-pinned-source", + "message": "Thread thr-file-evidence pins retracted source src-old-preprint.", + "context": { + "threadId": "thr-file-evidence", + "sourceId": "src-old-preprint", + "sourceStatus": "retracted" + } + }, + { + "severity": "blocker", + "code": "unknown-scope", + "message": "Thread thr-orphan-sidebar is attached to an unknown section scope.", + "context": { + "threadId": "thr-orphan-sidebar", + "scopeId": "sec-supplement" + } + }, + { + "severity": "warning", + "code": "decision-missing-source", + "message": "Decision dec-methods-draft has no pinned source evidence.", + "context": { + "decisionId": "dec-methods-draft" + } + }, + { + "severity": "blocker", + "code": "conflicting-decisions", + "message": "Multiple active decisions compete for sec-results:model-summary.", + "context": { + "decisionIds": [ + "dec-results-adjusted", + "dec-results-conflict" + ] + } + } + ], + "reviewerTasks": [ + { + "id": "thr-methods-blocker:resolve-blocker", + "threadId": "thr-methods-blocker", + "ownerId": "u-ada", + "title": "Resolve blocker: Resolve assay exclusion criteria", + "priority": "high" + } + ], + "exportPacket": { + "generatedAt": "2026-05-16T17:00:00.000Z", + "packetHash": "ebc3395f938f09def2f2aaa386b8e119a7c73314f3d7331e04e486b7a5fb638f", + "auditDigest": "scibase-paper-alpha:5:3:1:ebc3395f938f", + "entries": [ + { + "threadId": "thr-file-evidence", + "title": "Remove unsupported background citation", + "scopeType": "file", + "scopeId": "paper/introduction.md", + "sectionTitle": "Introduction", + "status": "resolved", + "blocking": false, + "ownerId": "u-max", + "participantIds": [ + "u-ada", + "u-max" + ], + "pinnedSourceIds": [ + "src-old-preprint" + ], + "decisionIds": [] + }, + { + "threadId": "thr-methods-blocker", + "title": "Resolve assay exclusion criteria", + "scopeType": "section", + "scopeId": "sec-methods", + "sectionTitle": "Methods", + "status": "open", + "blocking": true, + "ownerId": "u-ada", + "participantIds": [ + "u-ada" + ], + "pinnedSourceIds": [ + "src-protocol" + ], + "decisionIds": [ + "dec-methods-draft" + ] + }, + { + "threadId": "thr-orphan-sidebar", + "title": "Unattached sidebar note", + "scopeType": "section", + "scopeId": "sec-supplement", + "sectionTitle": null, + "status": "open", + "blocking": false, + "ownerId": "u-max", + "participantIds": [ + "u-max" + ], + "pinnedSourceIds": [ + "src-dataset" + ], + "decisionIds": [] + }, + { + "threadId": "thr-results-decision", + "title": "Accept adjusted model summary", + "scopeType": "section", + "scopeId": "sec-results", + "sectionTitle": "Results", + "status": "resolved", + "blocking": false, + "ownerId": "u-rin", + "participantIds": [ + "u-max", + "u-rin" + ], + "pinnedSourceIds": [ + "src-dataset" + ], + "decisionIds": [ + "dec-results-adjusted", + "dec-results-conflict" + ] + } + ] + } +} diff --git a/collaborative-discussion-sidebar-audit/docs/requirement-map.md b/collaborative-discussion-sidebar-audit/docs/requirement-map.md new file mode 100644 index 0000000..73aad9e --- /dev/null +++ b/collaborative-discussion-sidebar-audit/docs/requirement-map.md @@ -0,0 +1,14 @@ +# Requirement Map + +This package targets the "Document chat or discussion sidebar per file or section" requirement from SCIBASE issue #12. + +| Issue capability | Coverage in this package | +| --- | --- | +| Real-time collaboration readiness | Validates sidebar participants, open blockers, reviewer ownership, and locked-section participation before export. | +| Section and file comments | Models threads scoped to either manuscript sections or file paths, then flags orphaned threads. | +| Suggestions, decisions, and discussion history | Records accepted/proposed decisions and detects conflicting active decisions for the same section/topic. | +| Version history and autosave handoff | Emits a deterministic export packet with stable hash and audit digest suitable for snapshot history. | +| Scientific source traceability | Requires pinned source evidence for decisions and blocks unsafe or missing sources. | +| Reviewer task management | Converts unresolved blocking threads into explicit reviewer tasks with owners and priority. | + +The slice is deliberately narrow so it can be reviewed independently of larger editor, notebook, reference-formatting, and figure/table implementations. diff --git a/collaborative-discussion-sidebar-audit/package.json b/collaborative-discussion-sidebar-audit/package.json new file mode 100644 index 0000000..10b5fb1 --- /dev/null +++ b/collaborative-discussion-sidebar-audit/package.json @@ -0,0 +1,15 @@ +{ + "name": "collaborative-discussion-sidebar-audit", + "version": "1.0.0", + "description": "Section-scoped discussion sidebar audit tools for a collaborative research editor.", + "type": "module", + "private": true, + "scripts": { + "test": "node test/collaborative-discussion-sidebar-audit.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/discussion-sidebar-demo && /tmp/discussion-sidebar-demo docs/demo.mp4" + }, + "exports": { + ".": "./src/collaborative-discussion-sidebar-audit.js" + } +} diff --git a/collaborative-discussion-sidebar-audit/sample/discussion-sidebar-packet.json b/collaborative-discussion-sidebar-audit/sample/discussion-sidebar-packet.json new file mode 100644 index 0000000..06a1805 --- /dev/null +++ b/collaborative-discussion-sidebar-audit/sample/discussion-sidebar-packet.json @@ -0,0 +1,185 @@ +{ + "project": { + "id": "scibase-paper-alpha", + "title": "Multimodal biomarker screening draft", + "reviewRound": "methods-freeze" + }, + "participants": [ + { + "id": "u-ada", + "name": "Ada", + "role": "owner" + }, + { + "id": "u-max", + "name": "Max", + "role": "reviewer" + }, + { + "id": "u-rin", + "name": "Rin", + "role": "statistician" + } + ], + "sections": [ + { + "id": "sec-introduction", + "title": "Introduction", + "filePath": "paper/introduction.md", + "locked": false, + "ownerId": "u-ada" + }, + { + "id": "sec-methods", + "title": "Methods", + "filePath": "paper/methods.md", + "locked": true, + "ownerId": "u-ada" + }, + { + "id": "sec-results", + "title": "Results", + "filePath": "paper/results.md", + "locked": true, + "ownerId": "u-rin" + } + ], + "sources": [ + { + "id": "src-protocol", + "title": "Screening protocol v4", + "type": "protocol", + "status": "accepted" + }, + { + "id": "src-dataset", + "title": "Normalized biomarker dataset", + "type": "dataset", + "status": "active" + }, + { + "id": "src-old-preprint", + "title": "Withdrawn preprint", + "type": "article", + "status": "retracted" + } + ], + "threads": [ + { + "id": "thr-methods-blocker", + "title": "Resolve assay exclusion criteria", + "scopeType": "section", + "scopeId": "sec-methods", + "status": "open", + "blocking": true, + "ownerId": "u-ada", + "participantIds": ["u-ada"], + "updatedAt": "2026-05-08T10:15:00.000Z", + "pinnedSourceIds": ["src-protocol"], + "messages": [ + { + "authorId": "u-ada", + "createdAt": "2026-05-08T10:15:00.000Z", + "body": "Need reviewer confirmation before methods freeze." + } + ] + }, + { + "id": "thr-results-decision", + "title": "Accept adjusted model summary", + "scopeType": "section", + "scopeId": "sec-results", + "status": "resolved", + "blocking": false, + "ownerId": "u-rin", + "participantIds": ["u-rin", "u-max"], + "updatedAt": "2026-05-15T14:30:00.000Z", + "pinnedSourceIds": ["src-dataset"], + "messages": [ + { + "authorId": "u-rin", + "createdAt": "2026-05-15T13:20:00.000Z", + "body": "Adjusted model is ready for the discussion section." + }, + { + "authorId": "u-max", + "createdAt": "2026-05-15T14:30:00.000Z", + "body": "Approved with the dataset pin." + } + ] + }, + { + "id": "thr-file-evidence", + "title": "Remove unsupported background citation", + "scopeType": "file", + "scopeId": "paper/introduction.md", + "status": "resolved", + "blocking": false, + "ownerId": "u-max", + "participantIds": ["u-max", "u-ada"], + "updatedAt": "2026-05-14T11:00:00.000Z", + "pinnedSourceIds": ["src-old-preprint"], + "messages": [ + { + "authorId": "u-max", + "createdAt": "2026-05-14T11:00:00.000Z", + "body": "This source should not survive export." + } + ] + }, + { + "id": "thr-orphan-sidebar", + "title": "Unattached sidebar note", + "scopeType": "section", + "scopeId": "sec-supplement", + "status": "open", + "blocking": false, + "ownerId": "u-max", + "participantIds": ["u-max"], + "updatedAt": "2026-05-16T10:00:00.000Z", + "pinnedSourceIds": ["src-dataset"], + "messages": [ + { + "authorId": "u-max", + "createdAt": "2026-05-16T10:00:00.000Z", + "body": "This note should be reattached before export." + } + ] + } + ], + "decisions": [ + { + "id": "dec-results-adjusted", + "threadId": "thr-results-decision", + "scopeId": "sec-results", + "topicKey": "model-summary", + "status": "accepted", + "summary": "Use adjusted model summary in Results.", + "decidedBy": "u-max", + "decidedAt": "2026-05-15T14:30:00.000Z", + "sourceIds": ["src-dataset"] + }, + { + "id": "dec-results-conflict", + "threadId": "thr-results-decision", + "scopeId": "sec-results", + "topicKey": "model-summary", + "status": "accepted", + "summary": "Use unadjusted model summary instead.", + "decidedBy": "u-rin", + "decidedAt": "2026-05-15T14:45:00.000Z", + "sourceIds": ["src-dataset"] + }, + { + "id": "dec-methods-draft", + "threadId": "thr-methods-blocker", + "scopeId": "sec-methods", + "topicKey": "exclusion-criteria", + "status": "proposed", + "summary": "Draft exclusion text until reviewer approval arrives.", + "decidedBy": "u-ada", + "decidedAt": "2026-05-08T10:20:00.000Z", + "sourceIds": [] + } + ] +} diff --git a/collaborative-discussion-sidebar-audit/scripts/demo.js b/collaborative-discussion-sidebar-audit/scripts/demo.js new file mode 100644 index 0000000..e2eb088 --- /dev/null +++ b/collaborative-discussion-sidebar-audit/scripts/demo.js @@ -0,0 +1,57 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { auditDiscussionSidebar } from "../src/collaborative-discussion-sidebar-audit.js"; + +const packet = JSON.parse(readFileSync(new URL("../sample/discussion-sidebar-packet.json", import.meta.url), "utf8")); +const result = auditDiscussionSidebar(packet); + +mkdirSync(new URL("../docs/", import.meta.url), { recursive: true }); +writeFileSync(new URL("../docs/discussion-sidebar-audit-report.json", import.meta.url), `${JSON.stringify(result, null, 2)}\n`); +writeFileSync(new URL("../docs/demo.svg", import.meta.url), renderSvg(result)); + +console.log(`Discussion sidebar audit: ${result.ready ? "ready" : "not ready"}`); +console.log(`Project: ${result.project.title}`); +console.log(`Threads: ${result.counts.threads}`); +console.log(`Blockers: ${result.counts.blockers}`); +console.log(`Warnings: ${result.counts.warnings}`); +console.log(`Reviewer tasks: ${result.counts.reviewerTasks}`); +console.log(`Audit digest: ${result.exportPacket.auditDigest}`); + +function renderSvg(result) { + const blockers = result.findings.filter((finding) => finding.severity === "blocker"); + const warnings = result.findings.filter((finding) => finding.severity === "warning"); + const tasks = result.reviewerTasks; + + return ` + Collaborative discussion sidebar audit demo + A storyboard showing section-scoped discussion coverage, blockers, warnings, and export packet digest. + + + Discussion Sidebar Audit + Section-scoped threads, pinned evidence, decisions, and reviewer handoff packet + ${card(96, 194, "Coverage", `${result.sidebarCoverage.scopedSections}/${result.sidebarCoverage.totalSections} sections`, "Sidebar threads are attached to manuscript sections or files.")} + ${card(368, 194, "Blockers", String(result.counts.blockers), blockers.slice(0, 2).map((finding) => finding.code).join(" / "))} + ${card(640, 194, "Warnings", String(result.counts.warnings), warnings.slice(0, 2).map((finding) => finding.code).join(" / "))} + ${card(912, 194, "Tasks", String(tasks.length), tasks[0]?.title ?? "No reviewer tasks")} + + Export packet + ${escapeXml(result.exportPacket.auditDigest)} + Ready: ${result.ready ? "yes" : "no"} | Pinned sources: ${result.sidebarCoverage.pinnedSources} | Resolved threads: ${result.sidebarCoverage.resolvedThreads}/${result.sidebarCoverage.totalThreads} + Generated by npm run demo for SCIBASE issue #12 review. + +`; +} + +function card(x, y, label, value, detail) { + return ` + ${escapeXml(label)} + ${escapeXml(value)} + ${escapeXml(detail).slice(0, 46)}`; +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/collaborative-discussion-sidebar-audit/scripts/render-demo-video.m b/collaborative-discussion-sidebar-audit/scripts/render-demo-video.m new file mode 100644 index 0000000..54503de --- /dev/null +++ b/collaborative-discussion-sidebar-audit/scripts/render-demo-video.m @@ -0,0 +1,165 @@ +#import +#import + +static const NSInteger FrameWidth = 1280; +static const NSInteger FrameHeight = 720; +static const NSInteger FramesPerSecond = 30; +static const NSInteger TotalFrames = 150; + +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 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 DrawFrame(CGContextRef context, NSInteger frameIndex) { + NSGraphicsContext *graphicsContext = [NSGraphicsContext graphicsContextWithCGContext:context flipped:NO]; + [NSGraphicsContext saveGraphicsState]; + [NSGraphicsContext setCurrentContext:graphicsContext]; + + [[NSColor colorWithCalibratedRed:247.0 / 255.0 green:244.0 / 255.0 blue:238.0 / 255.0 alpha:1.0] setFill]; + NSRectFill(NSMakeRect(0, 0, FrameWidth, FrameHeight)); + + FillRounded(NSMakeRect(54, 48, 1172, 624), 18, [NSColor whiteColor]); + DrawText(@"Collaborative Discussion Sidebar Audit", 92, 604, 720, 44, 34, Color(36, 33, 29), YES); + DrawText(@"SCIBASE issue #12 slice: section chat, pinned evidence, decisions, and export packet.", 92, 570, 860, 28, 18, Color(93, 87, 80), NO); + + CGFloat progress = (CGFloat)frameIndex / (CGFloat)(TotalFrames - 1); + FillRounded(NSMakeRect(92, 534, 1080, 12), 6, Color(229, 223, 214)); + FillRounded(NSMakeRect(92, 534, 1080 * progress, 12), 6, Color(30, 129, 176)); + + NSArray *labels = @[@"Coverage", @"Blockers", @"Warnings", @"Tasks"]; + NSArray *values = @[@"3/3", @"5", @"3", @"1"]; + NSArray *details = @[@"sidebar scopes", @"must resolve", @"review notes", @"owner handoff"]; + for (NSInteger index = 0; index < 4; index++) { + CGFloat x = 92 + index * 270; + FillRounded(NSMakeRect(x, 374, 238, 130), 14, Color(241, 238, 231)); + DrawText(labels[index], x + 22, 460, 180, 24, 16, Color(93, 87, 80), YES); + DrawText(values[index], x + 22, 416, 160, 42, 32, Color(36, 33, 29), YES); + DrawText(details[index], x + 22, 394, 180, 22, 14, Color(112, 104, 95), NO); + } + + FillRounded(NSMakeRect(92, 146, 1088, 174), 16, Color(24, 35, 45)); + DrawText(@"Export packet", 126, 272, 240, 32, 22, [NSColor whiteColor], YES); + DrawText(@"scibase-paper-alpha:5:3:1:deterministic", 126, 226, 780, 30, 18, Color(201, 242, 208), NO); + DrawText(@"Ready: no | Pinned sources: 3 | Resolved threads: 2/4 | Deterministic handoff for version history", 126, 188, 920, 28, 17, Color(220, 228, 232), NO); + + CGFloat pulse = 0.45 + 0.35 * sin(progress * M_PI * 4.0); + NSColor *pulseColor = [NSColor colorWithCalibratedRed:30.0 / 255.0 green:129.0 / 255.0 blue:176.0 / 255.0 alpha:pulse]; + FillRounded(NSMakeRect(1008, 214, 126, 44), 22, pulseColor); + DrawText(@"demo.mp4", 1033, 226, 90, 22, 15, [NSColor whiteColor], YES); + + DrawText(@"Generated by npm run demo:video", 92, 92, 360, 24, 15, Color(93, 87, 80), 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 context = CGBitmapContextCreate(baseAddress, FrameWidth, FrameHeight, 8, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Host); + + DrawFrame(context, frameIndex); + + CGContextRelease(context); + 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/collaborative-discussion-sidebar-audit/src/collaborative-discussion-sidebar-audit.js b/collaborative-discussion-sidebar-audit/src/collaborative-discussion-sidebar-audit.js new file mode 100644 index 0000000..3ea5934 --- /dev/null +++ b/collaborative-discussion-sidebar-audit/src/collaborative-discussion-sidebar-audit.js @@ -0,0 +1,292 @@ +import { createHash } from "node:crypto"; + +const DEFAULT_POLICY = Object.freeze({ + reviewDate: "2026-05-16T17:00:00.000Z", + staleThreadDays: 5, + sourceStatusesAllowedForDecisions: ["active", "accepted", "draft"], + requiredLockedSectionRoles: ["owner", "reviewer"], + activeDecisionStatuses: ["accepted", "proposed"] +}); + +export function auditDiscussionSidebar(packet, policyOverrides = {}) { + const policy = { ...DEFAULT_POLICY, ...policyOverrides }; + const findings = []; + const reviewerTasks = []; + const exportEntries = []; + + const participantsById = indexBy(packet.participants, "id"); + const sectionsById = indexBy(packet.sections, "id"); + const sectionsByPath = new Map( + (packet.sections ?? []) + .filter((section) => section.filePath) + .map((section) => [section.filePath, section]) + ); + const sourcesById = indexBy(packet.sources, "id"); + const threadsById = indexBy(packet.threads, "id"); + + for (const thread of packet.threads ?? []) { + const scope = resolveScope(thread, sectionsById, sectionsByPath); + const threadActors = collectThreadActors(thread, participantsById); + const pinnedSources = resolveSources(thread.pinnedSourceIds, sourcesById); + + if (!scope.known) { + addFinding(findings, "blocker", "unknown-scope", `Thread ${thread.id} is attached to an unknown ${thread.scopeType} scope.`, { + threadId: thread.id, + scopeId: thread.scopeId + }); + } + + if (thread.blocking === true && thread.status !== "resolved") { + addFinding(findings, "blocker", "open-blocker", `Thread ${thread.id} is marked blocking and is still ${thread.status}.`, { + threadId: thread.id + }); + reviewerTasks.push({ + id: `${thread.id}:resolve-blocker`, + threadId: thread.id, + ownerId: thread.ownerId ?? null, + title: `Resolve blocker: ${thread.title}`, + priority: "high" + }); + } + + const staleDays = ageInDays(thread.updatedAt, policy.reviewDate); + if (thread.status !== "resolved" && staleDays > policy.staleThreadDays) { + const severity = thread.blocking ? "blocker" : "warning"; + addFinding(findings, severity, "stale-thread", `Thread ${thread.id} has been idle for ${staleDays} days.`, { + threadId: thread.id, + staleDays + }); + } + + for (const missingSourceId of pinnedSources.missing) { + addFinding(findings, "blocker", "missing-pinned-source", `Thread ${thread.id} pins missing source ${missingSourceId}.`, { + threadId: thread.id, + sourceId: missingSourceId + }); + } + + for (const source of pinnedSources.known) { + if (!policy.sourceStatusesAllowedForDecisions.includes(source.status)) { + addFinding(findings, "blocker", "unsafe-pinned-source", `Thread ${thread.id} pins ${source.status} source ${source.id}.`, { + threadId: thread.id, + sourceId: source.id, + sourceStatus: source.status + }); + } + } + + if (scope.section?.locked) { + const actorRoles = new Set(threadActors.map((actor) => actor.role)); + const missingRoles = policy.requiredLockedSectionRoles.filter((role) => !actorRoles.has(role)); + if (missingRoles.length > 0) { + addFinding(findings, "warning", "locked-section-missing-review-role", `Thread ${thread.id} on locked section ${scope.section.id} is missing ${missingRoles.join(", ")} participation.`, { + threadId: thread.id, + sectionId: scope.section.id, + missingRoles + }); + } + } + + for (const [index, message] of (thread.messages ?? []).entries()) { + if (!message.authorId || !participantsById.has(message.authorId)) { + addFinding(findings, "warning", "message-missing-author", `Thread ${thread.id} message ${index + 1} has no known author.`, { + threadId: thread.id, + messageIndex: index + }); + } + if (!message.createdAt || Number.isNaN(Date.parse(message.createdAt))) { + addFinding(findings, "warning", "message-missing-timestamp", `Thread ${thread.id} message ${index + 1} has no valid timestamp.`, { + threadId: thread.id, + messageIndex: index + }); + } + } + + exportEntries.push({ + threadId: thread.id, + title: thread.title, + scopeType: thread.scopeType, + scopeId: thread.scopeId, + sectionTitle: scope.section?.title ?? null, + status: thread.status, + blocking: thread.blocking === true, + ownerId: thread.ownerId ?? null, + participantIds: Array.from(new Set(threadActors.map((actor) => actor.id))).sort(), + pinnedSourceIds: pinnedSources.known.map((source) => source.id).sort(), + decisionIds: (packet.decisions ?? []) + .filter((decision) => decision.threadId === thread.id) + .map((decision) => decision.id) + .sort() + }); + } + + const activeDecisionGroups = new Map(); + for (const decision of packet.decisions ?? []) { + if (!threadsById.has(decision.threadId)) { + addFinding(findings, "blocker", "decision-missing-thread", `Decision ${decision.id} references missing thread ${decision.threadId}.`, { + decisionId: decision.id, + threadId: decision.threadId + }); + } + + const sourceIds = decision.sourceIds ?? []; + if (sourceIds.length === 0) { + addFinding(findings, "warning", "decision-missing-source", `Decision ${decision.id} has no pinned source evidence.`, { + decisionId: decision.id + }); + } + + for (const sourceId of sourceIds) { + const source = sourcesById.get(sourceId); + if (!source) { + addFinding(findings, "blocker", "decision-source-missing", `Decision ${decision.id} references missing source ${sourceId}.`, { + decisionId: decision.id, + sourceId + }); + } else if (!policy.sourceStatusesAllowedForDecisions.includes(source.status)) { + addFinding(findings, "blocker", "decision-source-unsafe", `Decision ${decision.id} references ${source.status} source ${source.id}.`, { + decisionId: decision.id, + sourceId: source.id, + sourceStatus: source.status + }); + } + } + + if (policy.activeDecisionStatuses.includes(decision.status)) { + const key = `${decision.scopeId}:${decision.topicKey}`; + const group = activeDecisionGroups.get(key) ?? []; + group.push(decision); + activeDecisionGroups.set(key, group); + } + } + + for (const [key, decisions] of activeDecisionGroups) { + const independentDecisions = decisions.filter((decision) => !decision.supersedes); + if (independentDecisions.length > 1) { + addFinding(findings, "blocker", "conflicting-decisions", `Multiple active decisions compete for ${key}.`, { + decisionIds: independentDecisions.map((decision) => decision.id).sort() + }); + } + } + + const counts = { + sections: (packet.sections ?? []).length, + threads: (packet.threads ?? []).length, + decisions: (packet.decisions ?? []).length, + blockers: findings.filter((finding) => finding.severity === "blocker").length, + warnings: findings.filter((finding) => finding.severity === "warning").length, + reviewerTasks: reviewerTasks.length + }; + + const sidebarCoverage = summarizeCoverage(packet, exportEntries); + const packetHash = digest({ + project: packet.project, + exportEntries, + findings, + reviewerTasks, + sidebarCoverage + }); + + return { + ready: counts.blockers === 0, + project: packet.project, + counts, + sidebarCoverage, + findings, + reviewerTasks, + exportPacket: { + generatedAt: policy.reviewDate, + packetHash, + auditDigest: `${packet.project?.id ?? "project"}:${counts.blockers}:${counts.warnings}:${reviewerTasks.length}:${packetHash.slice(0, 12)}`, + entries: exportEntries.sort((left, right) => left.threadId.localeCompare(right.threadId)) + } + }; +} + +function indexBy(items = [], field) { + return new Map(items.map((item) => [item[field], item])); +} + +function addFinding(findings, severity, code, message, context) { + findings.push({ severity, code, message, context }); +} + +function resolveScope(thread, sectionsById, sectionsByPath) { + if (thread.scopeType === "section") { + const section = sectionsById.get(thread.scopeId); + return { known: Boolean(section), section }; + } + if (thread.scopeType === "file") { + const section = sectionsByPath.get(thread.scopeId); + return { known: Boolean(section), section }; + } + return { known: false, section: null }; +} + +function collectThreadActors(thread, participantsById) { + const ids = new Set([thread.ownerId, ...(thread.participantIds ?? [])].filter(Boolean)); + for (const message of thread.messages ?? []) { + if (message.authorId) ids.add(message.authorId); + } + return Array.from(ids) + .map((id) => participantsById.get(id)) + .filter(Boolean); +} + +function resolveSources(sourceIds = [], sourcesById) { + const known = []; + const missing = []; + for (const sourceId of sourceIds) { + const source = sourcesById.get(sourceId); + if (source) { + known.push(source); + } else { + missing.push(sourceId); + } + } + return { known, missing }; +} + +function ageInDays(updatedAt, reviewDate) { + const updated = Date.parse(updatedAt); + const reviewed = Date.parse(reviewDate); + if (Number.isNaN(updated) || Number.isNaN(reviewed)) return 0; + return Math.max(0, Math.floor((reviewed - updated) / 86_400_000)); +} + +function summarizeCoverage(packet, exportEntries) { + const scopedSections = new Set( + exportEntries + .filter((entry) => entry.sectionTitle) + .map((entry) => entry.sectionTitle) + ); + const resolvedThreads = exportEntries.filter((entry) => entry.status === "resolved").length; + const pinnedSourceIds = new Set(exportEntries.flatMap((entry) => entry.pinnedSourceIds)); + const unscopedThreads = exportEntries.filter((entry) => !entry.sectionTitle).length; + + return { + scopedSections: scopedSections.size, + totalSections: (packet.sections ?? []).length, + resolvedThreads, + totalThreads: exportEntries.length, + pinnedSources: pinnedSourceIds.size, + unscopedThreads + }; +} + +function digest(value) { + return createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/collaborative-discussion-sidebar-audit/test/collaborative-discussion-sidebar-audit.test.js b/collaborative-discussion-sidebar-audit/test/collaborative-discussion-sidebar-audit.test.js new file mode 100644 index 0000000..68ad750 --- /dev/null +++ b/collaborative-discussion-sidebar-audit/test/collaborative-discussion-sidebar-audit.test.js @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { auditDiscussionSidebar } from "../src/collaborative-discussion-sidebar-audit.js"; + +const fixture = JSON.parse(readFileSync(new URL("../sample/discussion-sidebar-packet.json", import.meta.url), "utf8")); + +const audit = auditDiscussionSidebar(fixture); +assert.equal(audit.ready, false); +assert.equal(audit.counts.threads, 4); +assert.equal(audit.counts.blockers, 5); +assert.equal(audit.counts.warnings, 3); +assert.equal(audit.counts.reviewerTasks, 1); +assert.equal(audit.sidebarCoverage.scopedSections, 3); +assert.equal(audit.sidebarCoverage.pinnedSources, 3); +assert.match(audit.exportPacket.auditDigest, /^scibase-paper-alpha:5:3:1:[a-f0-9]{12}$/); + +const blockerCodes = audit.findings + .filter((finding) => finding.severity === "blocker") + .map((finding) => finding.code) + .sort(); +assert.deepEqual(blockerCodes, [ + "conflicting-decisions", + "open-blocker", + "stale-thread", + "unknown-scope", + "unsafe-pinned-source" +]); + +const cleanPacket = structuredClone(fixture); +cleanPacket.threads = [ + { + id: "thr-clean-methods", + title: "Methods reviewer sign-off", + scopeType: "section", + scopeId: "sec-methods", + status: "resolved", + blocking: false, + ownerId: "u-ada", + participantIds: ["u-ada", "u-max"], + updatedAt: "2026-05-16T12:00:00.000Z", + pinnedSourceIds: ["src-protocol"], + messages: [ + { + authorId: "u-ada", + createdAt: "2026-05-16T11:00:00.000Z", + body: "Ready for sign-off." + }, + { + authorId: "u-max", + createdAt: "2026-05-16T12:00:00.000Z", + body: "Signed off." + } + ] + } +]; +cleanPacket.decisions = [ + { + id: "dec-clean-methods", + threadId: "thr-clean-methods", + scopeId: "sec-methods", + topicKey: "exclusion-criteria", + status: "accepted", + summary: "Use protocol v4 exclusion criteria.", + decidedBy: "u-max", + decidedAt: "2026-05-16T12:00:00.000Z", + sourceIds: ["src-protocol"] + } +]; +const cleanAudit = auditDiscussionSidebar(cleanPacket); +assert.equal(cleanAudit.ready, true); +assert.equal(cleanAudit.counts.blockers, 0); +assert.equal(cleanAudit.counts.warnings, 0); +assert.equal(cleanAudit.exportPacket.entries.length, 1); + +const missingEvidencePacket = structuredClone(cleanPacket); +missingEvidencePacket.decisions[0].sourceIds = ["src-missing"]; +const missingEvidenceAudit = auditDiscussionSidebar(missingEvidencePacket); +assert.equal(missingEvidenceAudit.ready, false); +assert.equal( + missingEvidenceAudit.findings.some((finding) => finding.code === "decision-source-missing"), + true +); + +console.log("collaborative discussion sidebar audit tests passed");