From 6b14a241ede824eea561ce85fde53974ce1113fe Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 2 Jun 2026 14:08:07 -0700 Subject: [PATCH 01/91] Restore ScratchNode launch goal automation --- package.json | 10 + qa/run_demo_full.md | 23 + scripts/repo/mapReduceLocalHistory.ps1 | 246 ++++ scripts/repo/runWorkspaceHousekeeping.ps1 | 130 ++ scripts/repo/testWorkspaceHousekeeping.ps1 | 126 ++ scripts/repo/verifyWorkspaceHousekeeping.ps1 | 303 +++++ scripts/scratchnode/runLaunchGoalLoop.mjs | 467 +++++++ scripts/scratchnode/scanLaunch.mjs | 1259 ++++++++++++++++++ 8 files changed, 2564 insertions(+) create mode 100644 qa/run_demo_full.md create mode 100644 scripts/repo/mapReduceLocalHistory.ps1 create mode 100644 scripts/repo/runWorkspaceHousekeeping.ps1 create mode 100644 scripts/repo/testWorkspaceHousekeeping.ps1 create mode 100644 scripts/repo/verifyWorkspaceHousekeeping.ps1 create mode 100644 scripts/scratchnode/runLaunchGoalLoop.mjs create mode 100644 scripts/scratchnode/scanLaunch.mjs diff --git a/package.json b/package.json index 1bcfdd03..0ef81307 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,16 @@ "lint:design:fix": "node scripts/ui/designLinter.mjs --fix-suggestions", "lint:agent-ui": "node scripts/ui/agentNativeUiLinter.mjs", "lint:agent-ui:json": "node scripts/ui/agentNativeUiLinter.mjs --json", + "repo:augment:check": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/repo/checkAugmentUploadScope.ps1", + "repo:history:map": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/repo/mapReduceLocalHistory.ps1", + "repo:housekeeping": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/repo/runWorkspaceHousekeeping.ps1", + "repo:housekeeping:verify": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/repo/verifyWorkspaceHousekeeping.ps1", + "repo:housekeeping:self-test": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/repo/testWorkspaceHousekeeping.ps1", + "repo:housekeeping:check": "npm run repo:augment:check && npm run repo:housekeeping:verify && git diff --cached --check", + "scratchnode:launch:scan": "node scripts/scratchnode/scanLaunch.mjs", + "scratchnode:launch:check": "node scripts/scratchnode/scanLaunch.mjs --live", + "scratchnode:launch:interactive": "node scripts/scratchnode/scanLaunch.mjs --live --interactive", + "scratchnode:launch:goal": "node scripts/scratchnode/runLaunchGoalLoop.mjs", "dogfood:qa:gemini": "node scripts/ui/runDogfoodGeminiQa.mjs", "dogfood:loop": "node scripts/ui/runDogfoodGeminiQa.mjs --loop --max-iterations 5 --target-score 100 --target-aspiration 92 --design-edits", "dogfood:loop:auto": "node scripts/ui/runDogfoodGeminiQa.mjs --loop --auto-apply --max-iterations 5 --target-score 100 --target-aspiration 92 --design-edits", diff --git a/qa/run_demo_full.md b/qa/run_demo_full.md new file mode 100644 index 00000000..dc386223 --- /dev/null +++ b/qa/run_demo_full.md @@ -0,0 +1,23 @@ +# ScratchNode Full Demo Regression Oracle + +The full demo is the product-story oracle. A change is not an improvement if it breaks this loop. + +Expected story: + +1. Participants enter the event. +2. Public messages appear. +3. A `/ask` parent row appears. +4. A sourced answer appears under the parent question. +5. The answer trace states no private notes were used. +6. A private note is saved outside the public feed. +7. Attendee suggests an answer for FAQ. +8. Host promotes the answer. +9. Public wiki publishes without private notes. +10. User opens NodeBench handoff. +11. NodeBench shows event artifact plus private-note continuation. + +Verification command: + +```bash +npm run scratchnode:launch:goal +``` diff --git a/scripts/repo/mapReduceLocalHistory.ps1 b/scripts/repo/mapReduceLocalHistory.ps1 new file mode 100644 index 00000000..ee9dd9ae --- /dev/null +++ b/scripts/repo/mapReduceLocalHistory.ps1 @@ -0,0 +1,246 @@ +param( + [switch]$ApplySafe, + [switch]$ApplyCleanWorktrees, + [string]$Out = ".tmp/local-history-map-reduce.json" +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path ".").Path +$repoRootFull = [System.IO.Path]::GetFullPath($repoRoot) + +function Normalize-RepoPath([string]$Path) { + return ($Path -replace "\\", "/").TrimEnd("/") +} + +function Get-RepoRelativePath([string]$Path) { + $full = [System.IO.Path]::GetFullPath($Path) + if ($full.Equals($repoRootFull, [System.StringComparison]::OrdinalIgnoreCase)) { + return "." + } + if ($full.StartsWith($repoRootFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + return Normalize-RepoPath $full.Substring($repoRootFull.Length + 1) + } + return $null +} + +function Assert-InRepo([string]$Path) { + $full = [System.IO.Path]::GetFullPath($Path) + if (-not $full.StartsWith($repoRootFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to modify path outside repo: $full" + } + return $full +} + +function New-Entry([string]$Bucket, [string]$Path, [string]$Reason, [hashtable]$Extra = @{}) { + $absolute = if ([System.IO.Path]::IsPathRooted($Path)) { [System.IO.Path]::GetFullPath($Path) } else { [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) } + $entry = [ordered]@{ + bucket = $Bucket + path = Normalize-RepoPath $Path + absolutePath = $absolute + reason = $Reason + } + foreach ($key in $Extra.Keys) { + $entry[$key] = $Extra[$key] + } + return [pscustomobject]$entry +} + +function Add-Entry($Buckets, $Entry) { + $Buckets[$Entry.bucket].Add($Entry) | Out-Null +} + +function Get-Worktrees { + $items = @() + $current = $null + foreach ($line in & git worktree list --porcelain) { + if ([string]::IsNullOrWhiteSpace($line)) { + if ($current) { $items += [pscustomobject]$current } + $current = $null + continue + } + if ($line.StartsWith("worktree ")) { + if ($current) { $items += [pscustomobject]$current } + $current = [ordered]@{ path = $line.Substring("worktree ".Length); locked = $false; branch = $null; head = $null } + continue + } + if (-not $current) { continue } + if ($line.StartsWith("branch ")) { + $current.branch = $line.Substring("branch ".Length) + } elseif ($line.StartsWith("HEAD ")) { + $current.head = $line.Substring("HEAD ".Length) + } elseif ($line.StartsWith("locked")) { + $current.locked = $true + } + } + if ($current) { $items += [pscustomobject]$current } + return $items +} + +function Invoke-GitQuiet([string[]]$Arguments) { + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& git @Arguments 2>$null) + $exitCode = $LASTEXITCODE + return [pscustomobject]@{ output = $output; exitCode = $exitCode } + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } +} + +function Test-WorktreeUsable([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $probe = Invoke-GitQuiet @("-C", $Path, "rev-parse", "--is-inside-work-tree") + return $probe.exitCode -eq 0 -and (($probe.output | Select-Object -First 1) -eq "true") +} + +function Test-WorktreeDirty([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $statusResult = Invoke-GitQuiet @("-C", $Path, "status", "--porcelain") + if ($statusResult.exitCode -ne 0) { return $false } + $status = @($statusResult.output) + return $status.Count -gt 0 +} + +$buckets = [ordered]@{ + safe = [System.Collections.Generic.List[object]]::new() + caution = [System.Collections.Generic.List[object]]::new() + keep = [System.Collections.Generic.List[object]]::new() + external_report_only = [System.Collections.Generic.List[object]]::new() + nested_report_only = [System.Collections.Generic.List[object]]::new() +} + +$tmpKeep = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +@( + "augment-upload-scope.json", + "workspace-footprint.json", + "local-history-map-reduce.json", + "workspace-housekeeping-loop.json", + "workspace-housekeeping-verification.json", + "workspace-housekeeping-self-test.json" +) | ForEach-Object { $tmpKeep.Add($_) | Out-Null } + +$tmpPath = Join-Path $repoRoot ".tmp" +if (Test-Path -LiteralPath $tmpPath) { + foreach ($child in Get-ChildItem -LiteralPath $tmpPath -Force -ErrorAction SilentlyContinue) { + if (-not $tmpKeep.Contains($child.Name)) { + Add-Entry $buckets (New-Entry "safe" (Join-Path ".tmp" $child.Name) "generated .tmp child") + } + } +} + +foreach ($safeRoot in @("test-results", "playwright-report", "scripts/eval-harness/results")) { + $path = Join-Path $repoRoot $safeRoot + if (Test-Path -LiteralPath $path) { + foreach ($child in Get-ChildItem -LiteralPath $path -Force -ErrorAction SilentlyContinue) { + if ($child.Name -ne ".gitignore") { + Add-Entry $buckets (New-Entry "safe" (Join-Path $safeRoot $child.Name) "generated test/eval output") + } + } + } +} + +$stats = [ordered]@{ locked = 0; lockedAlive = 0; lockedStale = 0; dirty = 0; cleanLocked = 0; invalidRegistered = 0 } +$nested = [ordered]@{ registered = 0; existing = 0; missing = 0; invalid = 0; dirty = 0; locked = 0; lockedAlive = 0; lockedStale = 0 } +$external = [ordered]@{ registered = 0; existing = 0; missing = 0; invalid = 0; dirty = 0; locked = 0 } + +foreach ($wt in Get-Worktrees) { + $absolute = [System.IO.Path]::GetFullPath($wt.path) + $relative = Get-RepoRelativePath $absolute + $exists = Test-Path -LiteralPath $absolute + $isExternal = $null -eq $relative + $isNested = -not $isExternal -and ($relative -like ".worktrees/*" -or $relative -like ".claude/worktrees/*") + $isRequired = -not $isExternal -and $relative -eq ".worktrees/prod-parity-runtime" + $isUsable = Test-WorktreeUsable $absolute + $isDirty = if ($isUsable) { Test-WorktreeDirty $absolute } else { $false } + $isLocked = [bool]$wt.locked + + if ($exists -and -not $isUsable) { $stats.invalidRegistered += 1 } + if ($isDirty) { $stats.dirty += 1 } + if ($isLocked) { + $stats.locked += 1 + $stats.lockedStale += 1 + if (-not $isDirty) { $stats.cleanLocked += 1 } + } + + $entryPath = if ($relative) { $relative } else { $absolute } + $extra = @{ dirty = $isDirty; locked = $isLocked; lockAlive = $null; branch = $wt.branch; exists = $exists; gitUsable = $isUsable } + + if ($isExternal) { + $external.registered += 1 + if ($exists) { $external.existing += 1 } else { $external.missing += 1 } + if ($exists -and -not $isUsable) { $external.invalid += 1 } + if ($isDirty) { $external.dirty += 1 } + if ($isLocked) { $external.locked += 1 } + Add-Entry $buckets (New-Entry "external_report_only" $entryPath "external registered worktree; report only" $extra) + continue + } + + if ($isNested) { + $nested.registered += 1 + if ($exists) { $nested.existing += 1 } else { $nested.missing += 1 } + if ($exists -and -not $isUsable) { $nested.invalid += 1 } + if ($isDirty) { $nested.dirty += 1 } + if ($isLocked) { $nested.locked += 1; $nested.lockedStale += 1 } + } + + if ($relative -eq ".") { + Add-Entry $buckets (New-Entry "keep" $entryPath "primary worktree" $extra) + } elseif ($isRequired) { + Add-Entry $buckets (New-Entry "keep" $entryPath "required prod-parity worktree" $extra) + } elseif (-not $exists) { + Add-Entry $buckets (New-Entry "keep" $entryPath "missing registered worktree; inspect git metadata first" $extra) + } elseif (-not $isUsable) { + Add-Entry $buckets (New-Entry "keep" $entryPath "invalid registered worktree; inspect git metadata first" $extra) + } elseif ($isDirty) { + Add-Entry $buckets (New-Entry "keep" $entryPath "dirty registered worktree" $extra) + } elseif ($isLocked) { + Add-Entry $buckets (New-Entry "keep" $entryPath "locked registered worktree" $extra) + } elseif ($isNested) { + Add-Entry $buckets (New-Entry "caution" $entryPath "clean registered worktree; explicit prune only" $extra) + } else { + Add-Entry $buckets (New-Entry "keep" $entryPath "registered worktree outside cleanup roots" $extra) + } +} + +$actions = [ordered]@{ safeCleanupApplied = [bool]$ApplySafe; cleanWorktreePruneApplied = [bool]$ApplyCleanWorktrees; removedSafe = @(); prunedWorktrees = @() } + +if ($ApplySafe) { + foreach ($entry in @($buckets.safe)) { + $target = Assert-InRepo $entry.absolutePath + if (Test-Path -LiteralPath $target) { + Remove-Item -LiteralPath $target -Recurse -Force + $actions.removedSafe += $entry.path + } + } +} + +if ($ApplyCleanWorktrees) { + foreach ($entry in @($buckets.caution)) { + $target = Assert-InRepo $entry.absolutePath + if (Test-Path -LiteralPath $target) { + & git worktree remove --force $target | Out-Null + $actions.prunedWorktrees += $entry.path + } + } +} + +$report = [ordered]@{ + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + repo = $repoRoot + summary = [ordered]@{ + safe = [ordered]@{ entries = $buckets.safe.Count } + caution = [ordered]@{ entries = $buckets.caution.Count } + keep = [ordered]@{ entries = $buckets.keep.Count } + } + stats = $stats + nestedSummary = $nested + externalSummary = $external + buckets = $buckets + actions = $actions +} + +$outPath = Join-Path $repoRoot $Out +New-Item -ItemType Directory -Path (Split-Path -Parent $outPath) -Force | Out-Null +[pscustomobject]$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $outPath -Encoding utf8 +[pscustomobject]$report | Select-Object generatedAt, repo, summary, stats, nestedSummary, externalSummary, actions | ConvertTo-Json -Depth 8 diff --git a/scripts/repo/runWorkspaceHousekeeping.ps1 b/scripts/repo/runWorkspaceHousekeeping.ps1 new file mode 100644 index 00000000..accad412 --- /dev/null +++ b/scripts/repo/runWorkspaceHousekeeping.ps1 @@ -0,0 +1,130 @@ +param( + [switch]$ApplyCleanWorktrees, + [string[]]$ProtectedPaths = @("public/proto/home-v5.html"), + [string]$Out = ".tmp/workspace-housekeeping-loop.json" +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path ".").Path +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Invoke-JsonScript([string]$ScriptPath, [string[]]$Arguments = @()) { + $output = & powershell -NoProfile -ExecutionPolicy Bypass -File $ScriptPath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Script failed: $ScriptPath" + } + return ($output | Out-String).Trim() +} + +function Read-JsonFile([string]$Path) { + return Get-Content -Raw -LiteralPath (Join-Path $repoRoot $Path) | ConvertFrom-Json +} + +function Get-GitStatusLines { + return @(& git status --short --branch --untracked-files=all) +} + +function Test-GitDiffPath([string]$Path, [switch]$Cached) { + $arguments = @("diff", "--name-only") + if ($Cached) { + $arguments += "--cached" + } + $arguments += "--" + $arguments += $Path + return @(& git @arguments).Count -gt 0 +} + +function Get-ProtectedPathReport([string[]]$Paths) { + return @( + foreach ($path in $Paths) { + $unstagedDiff = Test-GitDiffPath $path + $stagedDiff = Test-GitDiffPath $path -Cached + [pscustomobject]@{ + path = $path + exists = Test-Path -LiteralPath (Join-Path $repoRoot $path) + unstagedDiff = $unstagedDiff + stagedDiff = $stagedDiff + clean = -not ($unstagedDiff -or $stagedDiff) + } + } + ) +} + +$footprintScript = Join-Path $scriptRoot "auditWorkspaceFootprint.ps1" +$augmentScript = Join-Path $scriptRoot "checkAugmentUploadScope.ps1" +$historyScript = Join-Path $scriptRoot "mapReduceLocalHistory.ps1" + +$statusBefore = Get-GitStatusLines +Invoke-JsonScript $footprintScript | Out-Null +Invoke-JsonScript $augmentScript | Out-Null +Invoke-JsonScript $historyScript | Out-Null + +$initialHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" +$safeBefore = [int]$initialHistory.summary.safe.entries +$removedSafe = @() +$prunedWorktrees = @() + +if ($safeBefore -gt 0) { + Invoke-JsonScript $historyScript @("-ApplySafe") | Out-Null + $safeCleanupHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" + $removedSafe = @($safeCleanupHistory.actions.removedSafe) +} + +if ($ApplyCleanWorktrees) { + Invoke-JsonScript $historyScript @("-ApplyCleanWorktrees") | Out-Null + $worktreeCleanupHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" + $prunedWorktrees = @($worktreeCleanupHistory.actions.prunedWorktrees) +} + +Invoke-JsonScript $historyScript | Out-Null + +$footprint = Read-JsonFile ".tmp/workspace-footprint.json" +$augment = Read-JsonFile ".tmp/augment-upload-scope.json" +$finalHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" +$statusAfter = Get-GitStatusLines +$protectedPathReport = @(Get-ProtectedPathReport $ProtectedPaths) +$protectedPathsClean = -not (@($protectedPathReport | Where-Object { -not $_.clean }).Count -gt 0) + +$report = [ordered]@{ + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + repo = $repoRoot + gitStatusBefore = $statusBefore + gitStatusAfter = $statusAfter + protectedPathsClean = $protectedPathsClean + protectedPaths = $protectedPathReport + footprint = [ordered]@{ + trackedFiles = $footprint.trackedFiles + detailedCounts = $footprint.detailedCounts + } + augmentScope = [ordered]@{ + threshold = $augment.threshold + passed = $augment.passed + candidateCountPassed = $augment.candidateCountPassed + candidateFiles = $augment.candidateFiles + trackedFiles = $augment.trackedFiles + trackedIncluded = $augment.trackedIncluded + trackedExcludedByAugmentignore = $augment.trackedExcludedByAugmentignore + untrackedIncluded = $augment.untrackedIncluded + untrackedExcludedByAugmentignore = $augment.untrackedExcludedByAugmentignore + criticalIgnoreProbesPassed = $augment.criticalIgnoreProbesPassed + criticalIgnoreProbeFailures = $augment.criticalIgnoreProbeFailures + } + initialHistory = $initialHistory.summary + finalHistory = $finalHistory.summary + finalStats = $finalHistory.stats + nestedSummary = $finalHistory.nestedSummary + externalSummary = $finalHistory.externalSummary + actions = [ordered]@{ + safeCleanupApplied = ($safeBefore -gt 0) + removedSafeCount = $removedSafe.Count + removedSafe = $removedSafe + cleanWorktreePruneApplied = [bool]$ApplyCleanWorktrees + prunedWorktreeCount = $prunedWorktrees.Count + prunedWorktrees = $prunedWorktrees + } +} + +$outPath = Join-Path $repoRoot $Out +New-Item -ItemType Directory -Path (Split-Path -Parent $outPath) -Force | Out-Null +[pscustomobject]$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $outPath -Encoding utf8 +[pscustomobject]$report | ConvertTo-Json -Depth 10 diff --git a/scripts/repo/testWorkspaceHousekeeping.ps1 b/scripts/repo/testWorkspaceHousekeeping.ps1 new file mode 100644 index 00000000..466f4538 --- /dev/null +++ b/scripts/repo/testWorkspaceHousekeeping.ps1 @@ -0,0 +1,126 @@ +param( + [string]$ProbeName = "housekeeping-self-test-safe-probe.txt", + [string]$Out = ".tmp/workspace-housekeeping-self-test.json" +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path ".").Path +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Normalize-RepoPath([string]$Path) { + return ($Path -replace "\\", "/").TrimEnd("/") +} + +function Invoke-JsonScript([string]$ScriptPath, [string[]]$Arguments = @()) { + $output = & powershell -NoProfile -ExecutionPolicy Bypass -File $ScriptPath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Script failed: $ScriptPath" + } + return ($output | Out-String).Trim() +} + +function Read-JsonFile([string]$Path) { + return Get-Content -Raw -LiteralPath (Join-Path $repoRoot $Path) | ConvertFrom-Json +} + +function Add-Message([System.Collections.Generic.List[string]]$Messages, [string]$Message) { + $Messages.Add($Message) | Out-Null +} + +if ($ProbeName.Contains("/") -or $ProbeName.Contains("\") -or $ProbeName.StartsWith("workspace-housekeeping-")) { + throw "ProbeName must be a simple disposable filename that does not look like a housekeeping report." +} + +$historyScript = Join-Path $scriptRoot "mapReduceLocalHistory.ps1" +$housekeepingScript = Join-Path $scriptRoot "runWorkspaceHousekeeping.ps1" +$verifierScript = Join-Path $scriptRoot "verifyWorkspaceHousekeeping.ps1" + +$probeRelative = Normalize-RepoPath ".tmp/$ProbeName" +$probeAbsolute = Join-Path $repoRoot $probeRelative +$tmpAbsolute = Join-Path $repoRoot ".tmp" + +New-Item -ItemType Directory -Path $tmpAbsolute -Force | Out-Null +if (Test-Path -LiteralPath $probeAbsolute) { + Remove-Item -LiteralPath $probeAbsolute -Force +} +Set-Content -LiteralPath $probeAbsolute -Encoding utf8 -Value "safe housekeeping self-test probe" + +Invoke-JsonScript $historyScript | Out-Null +$mappedHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" +$mappedSafeProbeEntries = @($mappedHistory.buckets.safe | Where-Object { (Normalize-RepoPath $_.path) -eq $probeRelative }) + +Invoke-JsonScript $housekeepingScript | Out-Null +$cleanupLoop = Read-JsonFile ".tmp/workspace-housekeeping-loop.json" +$removedSafe = @($cleanupLoop.actions.removedSafe | ForEach-Object { Normalize-RepoPath $_ }) + +$failures = [System.Collections.Generic.List[string]]::new() +if ($mappedSafeProbeEntries.Count -ne 1) { + Add-Message $failures "probe was not mapped to exactly one safe entry" +} +if (Test-Path -LiteralPath $probeAbsolute) { + Add-Message $failures "probe still exists after housekeeping" +} +if (-not [bool]$cleanupLoop.actions.safeCleanupApplied) { + Add-Message $failures "housekeeping did not report safe cleanup" +} +if (-not $removedSafe.Contains($probeRelative)) { + Add-Message $failures "removedSafe does not include the probe path" +} +if ([int]$cleanupLoop.finalHistory.safe.entries -ne 0) { + Add-Message $failures "final safe entries should be zero after cleanup" +} +if ([bool]$cleanupLoop.actions.cleanWorktreePruneApplied -or [int]$cleanupLoop.actions.prunedWorktreeCount -ne 0) { + Add-Message $failures "self-test must not prune clean worktrees" +} +if (-not [bool]$cleanupLoop.protectedPathsClean) { + Add-Message $failures "protected paths must remain clean" +} + +Invoke-JsonScript $verifierScript | Out-Null +$finalVerification = Read-JsonFile ".tmp/workspace-housekeeping-verification.json" +if (-not [bool]$finalVerification.passed) { + Add-Message $failures "final verifier did not pass after self-test cleanup" +} +if ([int]$finalVerification.summary.finalSafe -ne 0) { + Add-Message $failures "final verifier reports remaining safe entries" +} +if ([int]$finalVerification.summary.prunedWorktreeCount -ne 0) { + Add-Message $failures "final verifier reports pruned worktrees" +} + +$report = [pscustomobject]@{ + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + repo = $repoRoot + passed = $failures.Count -eq 0 + failures = @($failures) + probe = [pscustomobject]@{ + path = $probeRelative + mappedSafeEntries = $mappedSafeProbeEntries.Count + removed = -not (Test-Path -LiteralPath $probeAbsolute) + removedSafeMatched = $removedSafe.Contains($probeRelative) + } + cleanup = [pscustomobject]@{ + safeCleanupApplied = $cleanupLoop.actions.safeCleanupApplied + removedSafeCount = $cleanupLoop.actions.removedSafeCount + prunedWorktreeCount = $cleanupLoop.actions.prunedWorktreeCount + finalSafe = $cleanupLoop.finalHistory.safe.entries + protectedPathsClean = $cleanupLoop.protectedPathsClean + } + finalVerification = [pscustomobject]@{ + passed = $finalVerification.passed + operatorStatus = $finalVerification.operatorSummary.status + finalSafe = $finalVerification.summary.finalSafe + finalCaution = $finalVerification.summary.finalCaution + removedSafeCount = $finalVerification.summary.removedSafeCount + prunedWorktreeCount = $finalVerification.summary.prunedWorktreeCount + } +} + +$outPath = Join-Path $repoRoot $Out +New-Item -ItemType Directory -Path (Split-Path -Parent $outPath) -Force | Out-Null +$report | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $outPath -Encoding utf8 +$report | ConvertTo-Json -Depth 10 + +if ($failures.Count -gt 0) { + throw "Workspace housekeeping self-test failed with $($failures.Count) failure(s)." +} diff --git a/scripts/repo/verifyWorkspaceHousekeeping.ps1 b/scripts/repo/verifyWorkspaceHousekeeping.ps1 new file mode 100644 index 00000000..3728cabd --- /dev/null +++ b/scripts/repo/verifyWorkspaceHousekeeping.ps1 @@ -0,0 +1,303 @@ +param( + [switch]$SkipRun, + [string]$Report = ".tmp/workspace-housekeeping-loop.json", + [string]$Out = ".tmp/workspace-housekeeping-verification.json", + [int]$MaxSourceReportAgeSeconds = 600, + [int]$MaxFutureReportSkewSeconds = 30 +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path ".").Path +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Read-JsonFile([string]$Path) { + return Get-Content -Raw -LiteralPath (Join-Path $repoRoot $Path) | ConvertFrom-Json +} + +function Add-Message([System.Collections.Generic.List[string]]$Messages, [string]$Message) { + $Messages.Add($Message) | Out-Null +} + +function Normalize-FullPath([string]$Path) { + if ([string]::IsNullOrWhiteSpace($Path)) { + return $null + } + return [System.IO.Path]::GetFullPath($Path) +} + +function Test-ReportRepoMatch($ReportObject) { + $reportRepo = Normalize-FullPath $ReportObject.repo + return -not [string]::IsNullOrWhiteSpace($reportRepo) -and $reportRepo.Equals($repoRoot, [System.StringComparison]::OrdinalIgnoreCase) +} + +function Get-ReportAgeSeconds([string]$GeneratedAt, [datetime]$NowUtc) { + if ([string]::IsNullOrWhiteSpace($GeneratedAt)) { + return $null + } + + try { + $timestamp = [datetime]::Parse($GeneratedAt).ToUniversalTime() + return [Math]::Round(($NowUtc - $timestamp).TotalSeconds, 3) + } catch { + return $null + } +} + +function Test-GitIgnored([string]$Path) { + & git check-ignore -q -- $Path + return $LASTEXITCODE -eq 0 +} + +function Invoke-StagedDiffCheck { + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& git diff --cached --check 2>&1) + $exitCode = $LASTEXITCODE + return [pscustomobject]@{ + passed = $exitCode -eq 0 + exitCode = $exitCode + issues = @($output | ForEach-Object { $_.ToString() }) + } + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } +} + +function Get-GitPathList([string[]]$Arguments) { + $items = [System.Collections.Generic.List[string]]::new() + @(& git @Arguments) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { + $items.Add($_.ToString()) | Out-Null + } + return $items +} + +function Test-HousekeepingPath([string]$Path) { + $normalized = $Path -replace "\\", "/" + return ( + $normalized -eq "docs/runbooks/WORKSPACE_HOUSEKEEPING.md" -or + $normalized -eq "package.json" -or + $normalized -like "scripts/repo/*" + ) +} + +if (-not $SkipRun) { + $housekeepingScript = Join-Path $scriptRoot "runWorkspaceHousekeeping.ps1" + & powershell -NoProfile -ExecutionPolicy Bypass -File $housekeepingScript | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Housekeeping run failed before verification." + } +} + +$loop = Read-JsonFile $Report +$history = Read-JsonFile ".tmp/local-history-map-reduce.json" +$augment = Read-JsonFile ".tmp/augment-upload-scope.json" +$footprint = Read-JsonFile ".tmp/workspace-footprint.json" +$stagedDiffCheck = Invoke-StagedDiffCheck +$stagedPaths = Get-GitPathList @("diff", "--cached", "--name-only") +$unstagedPaths = Get-GitPathList @("diff", "--name-only") +$untrackedPaths = Get-GitPathList @("ls-files", "--others", "--exclude-standard") +$nonHousekeepingStagedPaths = @($stagedPaths | Where-Object { -not (Test-HousekeepingPath $_) }) +$nonHousekeepingUnstagedPaths = @($unstagedPaths | Where-Object { -not (Test-HousekeepingPath $_) }) +$nonHousekeepingUntrackedPaths = @($untrackedPaths | Where-Object { -not (Test-HousekeepingPath $_) }) +$driftSummary = [pscustomobject]@{ + stagedCount = $stagedPaths.Count + unstagedCount = $unstagedPaths.Count + untrackedCount = $untrackedPaths.Count + housekeepingOnly = ( + $nonHousekeepingStagedPaths.Count -eq 0 -and + $nonHousekeepingUnstagedPaths.Count -eq 0 -and + $nonHousekeepingUntrackedPaths.Count -eq 0 + ) + stagedPaths = @($stagedPaths) + unstagedPaths = @($unstagedPaths) + untrackedPaths = @($untrackedPaths) + nonHousekeepingStagedPaths = @($nonHousekeepingStagedPaths) + nonHousekeepingUnstagedPaths = @($nonHousekeepingUnstagedPaths) + nonHousekeepingUntrackedPaths = @($nonHousekeepingUntrackedPaths) +} + +$failures = [System.Collections.Generic.List[string]]::new() +$warnings = [System.Collections.Generic.List[string]]::new() +$nowUtc = (Get-Date).ToUniversalTime() + +$sourceReports = [ordered]@{ + loop = [pscustomobject]@{ + path = $Report + repo = $loop.repo + generatedAt = $loop.generatedAt + repoMatches = Test-ReportRepoMatch $loop + ageSeconds = Get-ReportAgeSeconds $loop.generatedAt $nowUtc + fresh = $false + } + history = [pscustomobject]@{ + path = ".tmp/local-history-map-reduce.json" + repo = $history.repo + generatedAt = $history.generatedAt + repoMatches = Test-ReportRepoMatch $history + ageSeconds = Get-ReportAgeSeconds $history.generatedAt $nowUtc + fresh = $false + } + augment = [pscustomobject]@{ + path = ".tmp/augment-upload-scope.json" + repo = $augment.repo + generatedAt = $augment.generatedAt + repoMatches = Test-ReportRepoMatch $augment + ageSeconds = Get-ReportAgeSeconds $augment.generatedAt $nowUtc + fresh = $false + } + footprint = [pscustomobject]@{ + path = ".tmp/workspace-footprint.json" + repo = $footprint.repo + generatedAt = $footprint.generatedAt + repoMatches = Test-ReportRepoMatch $footprint + ageSeconds = Get-ReportAgeSeconds $footprint.generatedAt $nowUtc + fresh = $false + } +} + +foreach ($property in $sourceReports.GetEnumerator()) { + if (-not [bool]$property.Value.repoMatches) { + Add-Message $failures "source report repo mismatch: $($property.Key)" + } + if ([string]::IsNullOrWhiteSpace($property.Value.generatedAt)) { + Add-Message $failures "source report missing generatedAt: $($property.Key)" + } + if ($null -eq $property.Value.ageSeconds) { + Add-Message $failures "source report generatedAt is not parseable: $($property.Key)" + } elseif ([double]$property.Value.ageSeconds -lt (-1 * $MaxFutureReportSkewSeconds)) { + Add-Message $failures "source report timestamp is too far in the future: $($property.Key) ageSeconds=$($property.Value.ageSeconds)" + } elseif ([double]$property.Value.ageSeconds -gt $MaxSourceReportAgeSeconds) { + Add-Message $failures "source report is stale: $($property.Key) ageSeconds=$($property.Value.ageSeconds)" + } else { + $property.Value.fresh = $true + } +} + +if (-not [bool]$stagedDiffCheck.passed) { + Add-Message $failures "git diff --cached --check must pass" +} +if (-not [bool]$loop.augmentScope.passed) { + Add-Message $failures "augmentScope.passed must be true" +} +if (-not [bool]$loop.augmentScope.candidateCountPassed) { + Add-Message $failures "augmentScope.candidateCountPassed must be true" +} +if (-not [bool]$loop.augmentScope.criticalIgnoreProbesPassed) { + Add-Message $failures "augmentScope.criticalIgnoreProbesPassed must be true" +} +if ([int]$loop.augmentScope.criticalIgnoreProbeFailures -ne 0) { + Add-Message $failures "augmentScope.criticalIgnoreProbeFailures must be 0" +} +if ([int]$loop.augmentScope.untrackedIncluded -ne 0) { + Add-Message $failures "augmentScope.untrackedIncluded must be 0" +} +if ([int]$loop.augmentScope.candidateFiles -gt [int]$loop.augmentScope.threshold) { + Add-Message $failures "candidate files exceed Augment threshold" +} +if (-not [bool]$loop.protectedPathsClean) { + Add-Message $failures "protectedPathsClean must be true" +} +foreach ($pathReport in @($loop.protectedPaths)) { + if (-not [bool]$pathReport.exists) { + Add-Message $failures "protected path missing: $($pathReport.path)" + } + if (-not [bool]$pathReport.clean) { + Add-Message $failures "protected path has staged or unstaged drift: $($pathReport.path)" + } +} +if ([int]$loop.finalHistory.safe.entries -ne 0) { + Add-Message $failures "finalHistory.safe.entries must be 0" +} +if ([bool]$loop.actions.cleanWorktreePruneApplied) { + Add-Message $failures "normal verification must not prune clean worktrees" +} +if ([int]$loop.actions.prunedWorktreeCount -ne 0) { + Add-Message $failures "prunedWorktreeCount must be 0 in normal verification" +} + +foreach ($reportPath in @( + ".tmp/workspace-housekeeping-loop.json", + ".tmp/local-history-map-reduce.json", + ".tmp/augment-upload-scope.json", + ".tmp/workspace-footprint.json", + ".tmp/workspace-housekeeping-verification.json", + ".tmp/workspace-housekeeping-self-test.json" +)) { + if (-not (Test-GitIgnored $reportPath)) { + Add-Message $failures "report path is not ignored by Git: $reportPath" + } +} + +if ([int]$loop.finalHistory.caution.entries -gt 0) { + Add-Message $warnings "caution worktrees present: $($loop.finalHistory.caution.entries)" +} +if ([int]$loop.finalStats.invalidRegistered -gt 0) { + Add-Message $warnings "invalid registered worktrees present: $($loop.finalStats.invalidRegistered)" +} +if ([int]$loop.nestedSummary.missing -gt 0) { + Add-Message $warnings "missing nested registered worktrees present: $($loop.nestedSummary.missing)" +} +if ([int]$loop.externalSummary.missing -gt 0) { + Add-Message $warnings "missing external registered worktrees present: $($loop.externalSummary.missing)" +} +if (-not [bool]$driftSummary.housekeepingOnly) { + Add-Message $warnings "non-housekeeping drift is present" +} + +$cautionEntries = @($history.buckets.caution | Select-Object path, reason, branch, dirty, locked, exists, gitUsable) +$operatorStatus = if ($failures.Count -gt 0) { "FAIL" } elseif ($warnings.Count -gt 0) { "WARN" } else { "PASS" } +$operatorMessage = if ($operatorStatus -eq "FAIL") { + "Housekeeping verification failed: $($failures.Count) failure(s), $($warnings.Count) warning(s)." +} elseif ($operatorStatus -eq "WARN") { + "Housekeeping verified with attention items: $($warnings.Count) warning(s)." +} else { + "Housekeeping verified: Augment $($loop.augmentScope.candidateFiles)/$($loop.augmentScope.threshold), safe=$($loop.finalHistory.safe.entries), caution=$($loop.finalHistory.caution.entries), protected paths clean, drift housekeeping-only." +} +$verification = [pscustomobject]@{ + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + repo = $repoRoot + passed = $failures.Count -eq 0 + operatorSummary = [pscustomobject]@{ + status = $operatorStatus + notifyRecommended = $operatorStatus -ne "PASS" + message = $operatorMessage + launchRelevantBlockers = @($failures) + attentionItems = @($warnings) + } + failures = @($failures) + warnings = @($warnings) + summary = [pscustomobject]@{ + candidateFiles = $loop.augmentScope.candidateFiles + threshold = $loop.augmentScope.threshold + criticalIgnoreProbesPassed = $loop.augmentScope.criticalIgnoreProbesPassed + untrackedIncluded = $loop.augmentScope.untrackedIncluded + finalSafe = $loop.finalHistory.safe.entries + finalCaution = $loop.finalHistory.caution.entries + finalKeep = $loop.finalHistory.keep.entries + protectedPathsClean = $loop.protectedPathsClean + removedSafeCount = $loop.actions.removedSafeCount + prunedWorktreeCount = $loop.actions.prunedWorktreeCount + invalidRegistered = $loop.finalStats.invalidRegistered + stagedDiffCheckPassed = $stagedDiffCheck.passed + housekeepingOnlyDrift = $driftSummary.housekeepingOnly + sourceReportsMatch = -not @($sourceReports.GetEnumerator() | Where-Object { -not [bool]$_.Value.repoMatches }).Count + sourceReportsFresh = -not @($sourceReports.GetEnumerator() | Where-Object { -not [bool]$_.Value.fresh }).Count + maxSourceReportAgeSeconds = $MaxSourceReportAgeSeconds + maxFutureReportSkewSeconds = $MaxFutureReportSkewSeconds + } + cautionEntries = $cautionEntries + stagedDiffCheck = $stagedDiffCheck + drift = $driftSummary + sourceReports = [pscustomobject]$sourceReports + augmentReportPassed = $augment.passed +} + +$outPath = Join-Path $repoRoot $Out +New-Item -ItemType Directory -Path (Split-Path -Parent $outPath) -Force | Out-Null +$verification | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $outPath -Encoding utf8 +$verification | ConvertTo-Json -Depth 10 + +if ($failures.Count -gt 0) { + throw "Workspace housekeeping verification failed with $($failures.Count) failure(s)." +} diff --git a/scripts/scratchnode/runLaunchGoalLoop.mjs b/scripts/scratchnode/runLaunchGoalLoop.mjs new file mode 100644 index 00000000..5740d977 --- /dev/null +++ b/scripts/scratchnode/runLaunchGoalLoop.mjs @@ -0,0 +1,467 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; + +const repoRoot = process.cwd(); +const args = new Set(process.argv.slice(2)); +const shouldPrintJson = args.has("--json"); +const outPath = resolve(repoRoot, ".tmp/scratchnode-launch-goal-loop.json"); + +const reportPaths = { + housekeeping: ".tmp/workspace-housekeeping-verification.json", + launch: ".tmp/scratchnode-launch-scan.json", + augment: ".tmp/augment-upload-scope.json", + housekeepingLoop: ".tmp/workspace-housekeeping-loop.json", + localHistory: ".tmp/local-history-map-reduce.json", + goalLoop: ".tmp/scratchnode-launch-goal-loop.json", +}; + +function tail(text, maxLength = 16_000) { + if (text.length <= maxLength) return text; + return `...[truncated ${text.length - maxLength} chars]\n${text.slice(-maxLength)}`; +} + +function readJson(relativePath) { + const absolutePath = resolve(repoRoot, relativePath); + if (!existsSync(absolutePath)) return null; + try { + return JSON.parse(readFileSync(absolutePath, "utf8").replace(/^\uFEFF/, "")); + } catch (error) { + return { + parseError: error instanceof Error ? error.message : String(error), + }; + } +} + +function run(command, commandArgs, options = {}) { + const started = performance.now(); + return new Promise((resolveRun) => { + const child = spawn(command, commandArgs, { + cwd: repoRoot, + env: process.env, + shell: process.platform === "win32", + windowsHide: true, + ...options, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + resolveRun({ + command: [command, ...commandArgs].join(" "), + exitCode: 1, + durationMs: Math.round(performance.now() - started), + stdout: tail(stdout), + stderr: tail(`${stderr}\n${error.message}`.trim()), + }); + }); + child.on("close", (exitCode) => { + resolveRun({ + command: [command, ...commandArgs].join(" "), + exitCode: exitCode ?? 1, + durationMs: Math.round(performance.now() - started), + stdout: tail(stdout), + stderr: tail(stderr), + }); + }); + }); +} + +function unique(items) { + return [...new Set(items.filter(Boolean))]; +} + +function knownCautionEntries(housekeepingReport) { + const entries = (housekeepingReport?.cautionEntries ?? []).filter((entry) => + /clean registered worktree; explicit prune only/i.test(entry.reason ?? ""), + ); + const invalidRegistered = Number(housekeepingReport?.summary?.invalidRegistered ?? 0); + if (invalidRegistered > 0) { + entries.push({ + path: "git worktree metadata", + reason: `invalid registered worktrees present: ${invalidRegistered}; keep-classified by local-history map/reduce`, + }); + } + return entries; +} + +function actionableAttentionItems(housekeepingReport) { + const attentionItems = housekeepingReport?.operatorSummary?.attentionItems ?? []; + const knownCleanWorktreeCautionCount = (housekeepingReport?.cautionEntries ?? []).filter((entry) => + /clean registered worktree; explicit prune only/i.test(entry.reason ?? ""), + ).length; + const invalidRegistered = Number(housekeepingReport?.summary?.invalidRegistered ?? 0); + return attentionItems.filter((item) => { + const cautionMatch = item.match(/^caution worktrees present: (\d+)$/i); + if (cautionMatch) return Number(cautionMatch[1]) !== knownCleanWorktreeCautionCount; + const invalidMatch = item.match(/^invalid registered worktrees present: (\d+)$/i); + if (invalidMatch) return Number(invalidMatch[1]) !== invalidRegistered; + return true; + }); +} + +function buildCriterion(name, ok, detail) { + return { + name, + ok: !!ok, + detail: detail ?? "", + }; +} + +function parseFrontmatter(text) { + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); + if (!match) return {}; + const data = {}; + for (const line of match[1].split(/\r?\n/)) { + const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!field) continue; + data[field[1]] = field[2].trim(); + } + return data; +} + +function walkMarkdownFiles(relativeDir) { + const root = resolve(repoRoot, relativeDir); + if (!existsSync(root)) return []; + const files = []; + const walk = (absoluteDir) => { + for (const item of readdirSync(absoluteDir)) { + const absolutePath = resolve(absoluteDir, item); + const stat = statSync(absolutePath); + if (stat.isDirectory()) { + walk(absolutePath); + } else if (/\.md$/i.test(item)) { + files.push(absolutePath); + } + } + }; + walk(root); + return files.sort(); +} + +function readGoalQueue() { + return walkMarkdownFiles("goals") + .map((absolutePath) => { + const text = readFileSync(absolutePath, "utf8").replace(/^\uFEFF/, ""); + const frontmatter = parseFrontmatter(text); + if (!frontmatter.id || !frontmatter.status || !frontmatter.mode) return null; + const heading = text.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? ""; + const goalSentence = text.match(/^#\s+Goal\s*\r?\n+\s*([^\r\n]+)/im)?.[1]?.trim() ?? ""; + const title = frontmatter.title ?? (heading && heading !== "Goal" ? heading : goalSentence) ?? frontmatter.id ?? absolutePath; + const relativePath = absolutePath.slice(repoRoot.length + 1).replace(/\\/g, "/"); + return { + id: frontmatter.id ?? relativePath.replace(/\.md$/i, "").replace(/\//g, "-"), + title, + surface: frontmatter.surface ?? "unknown", + priority: frontmatter.priority ?? "P3", + status: frontmatter.status ?? "queued", + mode: frontmatter.mode ?? "safe-local-development", + path: relativePath, + }; + }) + .filter(Boolean) + .filter((goal) => goal.status !== "done"); +} + +function priorityRank(priority) { + return { P0: 0, P1: 1, P2: 2, P3: 3 }[priority] ?? 4; +} + +function goalCardsToBacklog(goalCards) { + return goalCards + .filter((goal) => (goal.status === "queued" || goal.status === "active") && goal.mode === "safe-local-development") + .sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.path.localeCompare(b.path)) + .map((goal) => ({ + id: `goal-${goal.id}`, + surface: goal.surface, + area: "goal queue", + priority: goal.priority, + mode: goal.mode, + title: goal.title, + why: `Queued goal card: ${goal.path}`, + maxSlice: "Take one narrow, locally verifiable slice from this goal card; do not expand scope.", + suggestedVerification: + goal.surface === "NodeBench Runtime" + ? ["npm run scratchnode:launch:goal", "npx vitest run convex/__tests__/scratchnode.events.test.ts"] + : ["npm run scratchnode:launch:goal"], + sourcePath: goal.path, + })); +} + +function buildDevelopmentBacklog({ housekeepingReport, launchReport, gitStatus, actionableAttention, launchRelevantBlockers, goalCards }) { + const backlog = []; + + for (const blocker of launchRelevantBlockers) { + backlog.push({ + id: `blocker-${backlog.length + 1}`, + surface: "repo", + area: "release blocker", + priority: "P0", + mode: "fix-first", + title: blocker, + why: "Launch-relevant blockers outrank new product work.", + maxSlice: "Root-cause and fix only this blocker, then rerun the goal loop.", + suggestedVerification: ["npm run scratchnode:launch:goal", "git diff --check"], + }); + } + + for (const item of actionableAttention) { + backlog.push({ + id: `attention-${backlog.length + 1}`, + surface: "repo", + area: "housekeeping", + priority: "P1", + mode: "fix-first", + title: item, + why: "Actionable workspace drift makes future autonomous development less reliable.", + maxSlice: "Fix the smallest housekeeping cause without pruning caution worktrees.", + suggestedVerification: ["npm run repo:housekeeping:check", "npm run scratchnode:launch:goal"], + }); + } + + if (gitStatus) { + backlog.push({ + id: `drift-${backlog.length + 1}`, + surface: "repo", + area: "git drift", + priority: "P1", + mode: "human-gated", + title: "Existing git drift must be classified before new autonomous development", + why: "The loop cannot safely improve product code while unclassified user or agent changes are present.", + maxSlice: "Inspect drift, preserve user changes, and either commit verified agent work or report non-agent drift.", + suggestedVerification: ["git status --short", "git diff --check"], + }); + } + + if (backlog.length > 0) return backlog; + + const queuedGoalBacklog = goalCardsToBacklog(goalCards); + if (queuedGoalBacklog.length > 0) return queuedGoalBacklog; + + const launchChecks = launchReport?.staticChecks ?? []; + const hasGoalAutomationChecks = launchChecks.some((check) => check.plane === "goal-automation"); + const hasNodeBenchLiveChecks = (launchReport?.liveChecks ?? []).some((check) => /nodebench/i.test(check.name)); + const hasNodeBenchInteractiveChecks = (launchReport?.interactiveChecks ?? []).some((check) => /nodebench/i.test(check.name)); + + return [ + { + id: "dev-scratchnode-flow-depth", + surface: "scratchnode.live", + area: "product workflow", + priority: "P1", + mode: "safe-local-development", + title: "Deepen the safe ScratchNode workflow probe", + why: "The current probe opens core modals and toggles private mode; the next quality lift is proving more of the Join -> Chat -> /ask -> private note -> FAQ -> Wiki loop without mutating production.", + maxSlice: "Add one read-only/browser-safe assertion or one local fixture-backed Playwright scenario.", + suggestedVerification: [ + "npm run scratchnode:launch:goal", + "npx playwright test tests/e2e/scratchnode-demo-route-gate.spec.ts tests/e2e/scratchnode-live-route-honesty.spec.ts --project=chromium --workers=1 --reporter=list", + ], + }, + { + id: "dev-nodebench-handoff-depth", + surface: "nodebenchai.com", + area: "ScratchNode handoff", + priority: hasNodeBenchLiveChecks && hasNodeBenchInteractiveChecks ? "P2" : "P1", + mode: "safe-local-development", + title: "Strengthen NodeBench handoff verification", + why: "NodeBench is the private workspace direction; the public launch loop should keep proving that ScratchNode handoff CTAs and /scratchnode-events remain coherent.", + maxSlice: "Add one route assertion, copy/link invariant, or docs-backed detector for NodeBench handoff behavior.", + suggestedVerification: ["npm run scratchnode:launch:goal"], + }, + { + id: "dev-privacy-eval-depth", + surface: "convex/events.ts", + area: "privacy and agent reliability", + priority: "P1", + mode: "safe-local-development", + title: "Add or strengthen a privacy-boundary regression test", + why: "The public/private boundary is ScratchNode's highest-trust invariant and should keep gaining executable coverage.", + maxSlice: "Add one targeted test around /ask excluding private notes, parent /ask trace visibility, or normal-chat not invoking the agent.", + suggestedVerification: ["npx vitest run convex/__tests__/scratchnode.events.test.ts", "npm run scratchnode:launch:goal"], + }, + { + id: "dev-public-repo-polish", + surface: "public repo", + area: "launch positioning", + priority: "P2", + mode: "safe-local-development", + title: "Improve public repo clarity or export safety", + why: "The public repo should stay positioned as high-fidelity prototype plus serious architecture, not an unstructured monorepo dump.", + maxSlice: "Improve one README/runbook/export-script invariant or one public asset/check.", + suggestedVerification: ["npm run scratchnode:launch:scan", "npm run scratchnode:launch:goal"], + }, + { + id: "dev-performance-a11y-polish", + surface: "ScratchNode and NodeBench", + area: "performance/accessibility", + priority: "P2", + mode: "safe-local-development", + title: "Tighten one performance, mobile, or accessibility detector", + why: "Small detector gains compound across the continuous loop and prevent cosmetic regressions from silently shipping.", + maxSlice: "Add one static or browser assertion; avoid speculative visual redesign without screenshot evidence.", + suggestedVerification: ["npm run scratchnode:launch:interactive"], + }, + { + id: "dev-goal-loop-instrumentation", + surface: "automation", + area: "self-improvement loop", + priority: hasGoalAutomationChecks ? "P3" : "P1", + mode: "safe-local-development", + title: "Improve loop instrumentation and evidence quality", + why: "The loop should become easier to judge over time: clearer reports, better candidate ranking, and less noisy notifications.", + maxSlice: "Add one report field, detector, or runbook invariant that makes future autonomous work safer.", + suggestedVerification: ["npm run scratchnode:launch:scan", "npm run scratchnode:launch:goal"], + }, + ]; +} + +async function main() { + const commands = []; + commands.push(await run("npm", ["run", "repo:housekeeping:check"])); + commands.push(await run("npm", ["run", "scratchnode:launch:interactive"])); + commands.push(await run("git", ["status", "--short"])); + commands.push( + await run("git", [ + "check-ignore", + "-v", + reportPaths.housekeeping, + reportPaths.launch, + reportPaths.augment, + reportPaths.housekeepingLoop, + reportPaths.localHistory, + reportPaths.goalLoop, + ]), + ); + + const housekeepingReport = readJson(reportPaths.housekeeping); + const launchReport = readJson(reportPaths.launch); + const gitStatus = commands.find((command) => command.command === "git status --short")?.stdout.trim() ?? ""; + const ignoreCheck = commands.find((command) => command.command.startsWith("git check-ignore")); + const actionableAttention = actionableAttentionItems(housekeepingReport); + const launchRelevantBlockers = housekeepingReport?.operatorSummary?.launchRelevantBlockers ?? []; + const knownCautions = knownCautionEntries(housekeepingReport); + const goalQueue = readGoalQueue(); + const developmentBacklog = buildDevelopmentBacklog({ + housekeepingReport, + launchReport, + gitStatus, + actionableAttention, + launchRelevantBlockers, + goalCards: goalQueue, + }); + + const criteria = [ + buildCriterion( + "housekeeping command passes", + commands[0]?.exitCode === 0 && housekeepingReport?.passed === true, + housekeepingReport?.operatorSummary?.message, + ), + buildCriterion( + "ScratchNode static/live/interactive launch scan passes", + commands[1]?.exitCode === 0 && launchReport?.summary?.passed === true, + launchReport?.summary + ? `blockers=${launchReport.summary.blockers}, warnings=${launchReport.summary.warnings}, liveFailures=${launchReport.summary.liveFailures}, interactiveFailures=${launchReport.summary.interactiveFailures}` + : "missing launch report", + ), + buildCriterion( + "Augment upload scope stays under threshold", + housekeepingReport?.summary?.candidateFiles < housekeepingReport?.summary?.threshold, + `${housekeepingReport?.summary?.candidateFiles ?? "?"}/${housekeepingReport?.summary?.threshold ?? "?"}`, + ), + buildCriterion("safe local-history cleanup queue is empty", housekeepingReport?.summary?.finalSafe === 0), + buildCriterion("protected product/runtime paths are clean", housekeepingReport?.summary?.protectedPathsClean === true), + buildCriterion("source reports match repo and are fresh", housekeepingReport?.summary?.sourceReportsMatch === true && housekeepingReport?.summary?.sourceReportsFresh === true), + buildCriterion("git drift is clean after the loop", gitStatus.length === 0, gitStatus), + buildCriterion(".tmp loop reports are ignored", ignoreCheck?.exitCode === 0, ignoreCheck?.stdout.trim()), + buildCriterion("no launch-relevant blockers remain", launchRelevantBlockers.length === 0, launchRelevantBlockers.join("; ")), + buildCriterion("no actionable attention items remain", actionableAttention.length === 0, actionableAttention.join("; ")), + ]; + + const passed = criteria.every((criterion) => criterion.ok); + const report = { + generatedAt: new Date().toISOString(), + repo: repoRoot, + goal: { + id: "scratchnode-nodebench-development-goal-cron", + objective: "Keep ScratchNode and NodeBench continuously improving in small verified slices while preserving production safety.", + stopCondition: + "The loop is clean when housekeeping, Augment scope, ScratchNode static/live/interactive checks, NodeBench handoff checks, tmp-ignore probes, and git drift all pass with no launch-relevant blockers; a development slice is done only when it is locally verified and either committed or explicitly reported.", + sourceRefs: [ + "docs/runbooks/GOAL_MODE_RELEASE_AUTOPILOT.md", + "docs/runbooks/SCRATCHNODE_LAUNCH_DAY.md", + "docs/runbooks/WORKSPACE_HOUSEKEEPING.md", + ], + successCriteria: criteria, + }, + workflowModel: { + issueQueue: + "Batch findings into blockers, attention items, known-safe cautions, and one focused development candidate per loop.", + specialistPasses: [ + "housekeeping", + "ScratchNode product workflow", + "NodeBench handoff and workspace direction", + "privacy and agent reliability", + "performance/accessibility", + "public repo positioning", + ], + developmentCadence: + "If gates are red, fix the smallest blocker first. If gates are green, pick one safe-local-development backlog item, make a narrow improvement, verify it, and commit or report the residual risk.", + repeatedFailureRule: "After three repeated failures on the same gate, change strategy by instrumenting, isolating, rolling back the risky slice, or reducing scope.", + safetyBoundary: + "The loop may edit local source, tests, scripts, and docs, but is read-only against production: it navigates, opens modals, copies safe controls, and inspects reports without sending chat, creating events, publishing wikis, deploying, pushing, or mutating live user data.", + }, + summary: { + passed, + notifyRecommended: !passed, + failures: criteria.filter((criterion) => !criterion.ok).map((criterion) => criterion.name), + knownCautionCount: knownCautions.length, + actionableAttentionCount: actionableAttention.length, + launchRelevantBlockerCount: launchRelevantBlockers.length, + queuedGoalCount: goalQueue.filter((goal) => goal.status === "queued").length, + gitDriftClean: gitStatus.length === 0, + nextDevelopmentCandidate: developmentBacklog[0]?.id ?? null, + }, + commands, + reports: { + housekeeping: housekeepingReport, + launch: launchReport, + }, + knownCautionEntries: knownCautions, + actionableAttentionItems: actionableAttention, + launchRelevantBlockers, + goalQueue, + developmentBacklog, + gitStatus, + }; + + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`); + + if (shouldPrintJson) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log( + `ScratchNode launch goal loop: ${passed ? "PASS" : "FAIL"} ` + + `(failures=${report.summary.failures.length}, knownCautions=${knownCautions.length})`, + ); + console.log(`Report: ${outPath}`); + for (const failure of report.summary.failures) { + console.log(`- ${failure}`); + } + } + + if (!passed) process.exitCode = 1; +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exitCode = 1; +}); diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs new file mode 100644 index 00000000..b5ad734c --- /dev/null +++ b/scripts/scratchnode/scanLaunch.mjs @@ -0,0 +1,1259 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; + +const repoRoot = process.cwd(); +const args = new Set(process.argv.slice(2)); +const shouldRunLive = args.has("--live") || args.has("--interactive"); +const shouldRunInteractive = args.has("--interactive"); +const shouldPrintJson = args.has("--json"); +const shouldFailOnWarn = args.has("--fail-on-warn"); +const outPath = resolve(repoRoot, ".tmp/scratchnode-launch-scan.json"); + +const files = { + homeV5: "public/proto/home-v5.html", + docsHtml: "public/proto/docs.html", + vercel: "vercel.json", + scratchnodeConfig: "api/scratchnode-config.js", + events: "convex/events.ts", + notes: "convex/notes.ts", + users: "convex/users.ts", + exportScript: "scripts/repo/export-scratchnode-live-public.mjs", + goalLoopScript: "scripts/scratchnode/runLaunchGoalLoop.mjs", + splitRunbook: "docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md", + launchRunbook: "docs/runbooks/SCRATCHNODE_LAUNCH_DAY.md", + goalRunbook: "docs/runbooks/GOAL_MODE_RELEASE_AUTOPILOT.md", + goalQueue: "goals/README.md", + scratchnodeGoal: "goals/scratchnode/001-first-time-user-clarity.md", + nodebenchGoal: "goals/nodebench/001-event-handoff.md", + runtimeGoal: "goals/runtime/001-public-private-boundary.md", + demoQa: "qa/run_demo_full.md", + readme: "README.md", + license: "LICENSE", + security: "SECURITY.md", + contributing: "CONTRIBUTING.md", +}; + +const staticChecks = []; +const findings = []; +const liveChecks = []; +const interactiveChecks = []; + +function readText(relativePath) { + const absolutePath = resolve(repoRoot, relativePath); + if (!existsSync(absolutePath)) return ""; + return readFileSync(absolutePath, "utf8"); +} + +function lineFor(text, index) { + return text.slice(0, Math.max(0, index)).split(/\r?\n/).length; +} + +function maskPattern(text, pattern) { + return text.replace(pattern, (match) => + match + .split("") + .map((char) => (char === "\n" || char === "\r" ? char : " ")) + .join(""), + ); +} + +function maskComments(text) { + return maskPattern(maskPattern(text, //g), /\/\*[\s\S]*?\*\//g); +} + +function addCheck(check) { + staticChecks.push({ + ok: !!check.ok, + name: check.name, + plane: check.plane ?? "static", + detail: check.detail ?? "", + optional: !!check.optional, + }); +} + +function addFinding(finding) { + findings.push({ + id: `SN-${String(findings.length + 1).padStart(3, "0")}`, + severity: finding.severity ?? "warn", + safety: finding.safety ?? "human-gated", + plane: finding.plane ?? "static", + title: finding.title, + path: finding.path, + line: finding.line ?? null, + detail: finding.detail ?? "", + recommendation: finding.recommendation ?? "", + }); +} + +function addLiveCheck(check) { + liveChecks.push({ + ok: !!check.ok, + name: check.name, + url: check.url, + status: check.status ?? null, + durationMs: Math.round(check.durationMs ?? 0), + detail: check.detail ?? "", + optional: !!check.optional, + }); +} + +function addInteractiveCheck(check) { + interactiveChecks.push({ + ok: !!check.ok, + name: check.name, + url: check.url, + durationMs: Math.round(check.durationMs ?? 0), + detail: check.detail ?? "", + optional: !!check.optional, + }); +} + +function checkRequiredFile(relativePath, name = relativePath) { + const ok = existsSync(resolve(repoRoot, relativePath)); + addCheck({ ok, name: `required file: ${name}`, detail: relativePath }); + if (!ok) { + addFinding({ + severity: "blocker", + safety: "manual", + title: `Missing required launch file: ${name}`, + path: relativePath, + recommendation: "Restore or regenerate this file before public launch.", + }); + } +} + +function attrValue(tag, attr) { + const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i")); + return match?.[2] ?? match?.[3] ?? match?.[4] ?? null; +} + +function hasAttr(tag, attr) { + return new RegExp(`\\b${attr}\\s*=`, "i").test(tag); +} + +function isInsideForm(text, index) { + const before = text.slice(0, index); + return before.lastIndexOf(" before.lastIndexOf("]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? ""; + addCheck({ + ok: /ScratchNode/i.test(title), + name: "home-v5 title is ScratchNode branded", + detail: title, + }); + + const canonical = html.match(/]*rel=["']canonical["'][^>]*>/i)?.[0] ?? ""; + addCheck({ + ok: /https:\/\/scratchnode\.live\//i.test(canonical), + name: "home-v5 canonical points at scratchnode.live", + detail: canonical.slice(0, 180), + }); + + const firstTimeFlow = html.match(/]*data-first-time-flow[^>]*>[\s\S]*?<\/nav>/i)?.[0] ?? ""; + const hasFirstTimeClarityRail = + /aria-label=["']First-time attendee flow["']/i.test(firstTimeFlow) && + /data-flow-step=["']join["']/i.test(firstTimeFlow) && + /data-flow-step=["']chat["']/i.test(firstTimeFlow) && + /data-flow-step=["']ask["']/i.test(firstTimeFlow) && + /data-flow-step=["']private-note["']/i.test(firstTimeFlow) && + /data-flow-step=["']wiki["']/i.test(firstTimeFlow); + addCheck({ + ok: hasFirstTimeClarityRail, + name: "home-v5 has first-time attendee flow rail", + plane: "product-clarity", + detail: "join -> chat -> /ask -> private note -> wiki", + }); + if (!hasFirstTimeClarityRail) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "product-clarity", + title: "First-time attendee flow rail is missing or incomplete", + path, + recommendation: + "Expose Join, Chat, /ask, Private note, and Wiki as one scannable first-viewport flow in home-v5.", + }); + } + + const h1Matches = [...masked.matchAll(/ 1) { + addFinding({ + severity: "warn", + safety: "human-gated", + title: "Multiple static H1 elements in home-v5", + path, + line: lineFor(html, h1Matches[1].index ?? 0), + detail: `Detected ${h1Matches.length} static H1 tags.`, + recommendation: "Keep one page-level H1 and downgrade secondary headings after visual review.", + }); + } + + for (const match of masked.matchAll(/]*>/gi)) { + const tag = match[0]; + if (hasAttr(tag, "type")) continue; + const line = lineFor(html, match.index ?? 0); + const inForm = isInsideForm(masked, match.index ?? 0); + addFinding({ + severity: "warn", + safety: inForm ? "human-gated" : "auto", + title: inForm ? "Button inside form missing explicit type" : "Button outside form missing type=\"button\"", + path, + line, + detail: tag.slice(0, 180), + recommendation: inForm + ? "Review whether the button should submit or be type=\"button\"." + : "Add type=\"button\"; outside forms this is behavior-preserving.", + }); + } + + for (const match of masked.matchAll(/]*target\s*=\s*["']?_blank["']?[^>]*>/gi)) { + const tag = match[0]; + const rel = attrValue(tag, "rel") ?? ""; + if (/\bnoopener\b/i.test(rel)) continue; + addFinding({ + severity: "warn", + safety: "auto", + title: "target=_blank link missing rel=noopener", + path, + line: lineFor(html, match.index ?? 0), + detail: tag.slice(0, 180), + recommendation: "Add rel=\"noopener noreferrer\".", + }); + } + + for (const match of masked.matchAll(/]*>/gi)) { + const tag = match[0]; + const line = lineFor(html, match.index ?? 0); + if (!hasAttr(tag, "alt")) { + addFinding({ + severity: "warn", + safety: "auto", + title: "Image missing alt attribute", + path, + line, + detail: tag.slice(0, 180), + recommendation: "Add descriptive alt text or alt=\"\" for decorative images.", + }); + } + if (line > 2300 && !hasAttr(tag, "loading")) { + addFinding({ + severity: "warn", + safety: "auto", + title: "Likely below-fold image missing lazy loading", + path, + line, + detail: tag.slice(0, 180), + recommendation: "Add loading=\"lazy\" after confirming it is not first-viewport media.", + }); + } + } + + const cssBlocks = [...html.matchAll(/[^{}]+{[^{}]*backdrop-filter\s*:[^{}]*}/gi)]; + for (const match of cssBlocks) { + const block = match[0]; + if (/-webkit-backdrop-filter\s*:/i.test(block)) continue; + addFinding({ + severity: "warn", + safety: "auto", + title: "backdrop-filter rule missing Safari prefix", + path, + line: lineFor(html, match.index ?? 0), + detail: block.slice(0, 220).replace(/\s+/g, " "), + recommendation: "Mirror the rule with -webkit-backdrop-filter for iOS Safari.", + }); + } + + for (const match of html.matchAll(/\bsetInterval\s*\(/g)) { + const start = Math.max(0, (match.index ?? 0) - 500); + const end = Math.min(html.length, (match.index ?? 0) + 900); + const windowText = html.slice(start, end); + const hasHiddenGuard = /document\.hidden|visibilitychange|clearInterval/i.test(windowText); + if (!hasHiddenGuard) { + addFinding({ + severity: "warn", + safety: "human-gated", + title: "Polling interval lacks nearby visibility/cleanup guard", + path, + line: lineFor(html, match.index ?? 0), + detail: "setInterval without nearby document.hidden, visibilitychange, or clearInterval signal.", + recommendation: "Gate polling while document.hidden and clear intervals on teardown where practical.", + }); + } + } + + const listenerCount = [...html.matchAll(/\baddEventListener\s*\(/g)].length; + const removeListenerCount = [...html.matchAll(/\bremoveEventListener\s*\(/g)].length; + addCheck({ + ok: listenerCount <= 30 || removeListenerCount > 0, + name: "interactive listener cleanup signal", + detail: `addEventListener=${listenerCount}, removeEventListener=${removeListenerCount}`, + optional: true, + }); + if (listenerCount > 30 && removeListenerCount === 0) { + addFinding({ + severity: "warn", + safety: "human-gated", + title: "Many event listeners with no removeEventListener signal", + path, + detail: `Detected ${listenerCount} addEventListener calls and no removeEventListener calls.`, + recommendation: "Audit lifecycle for route changes, overlays, and repeated event joins.", + }); + } + + for (const match of html.matchAll(/["']\/scratchnode-events(?:[?#][^"']*)?["']/gi)) { + addFinding({ + severity: "blocker", + safety: "manual", + title: "Relative /scratchnode-events link in ScratchNode shell", + path, + line: lineFor(html, match.index ?? 0), + detail: match[0], + recommendation: "Use absolute https://nodebenchai.com/scratchnode-events links from scratchnode.live.", + }); + } + + addCheck({ + ok: /PUBLIC_BASE_URL/.test(html) && /WORKSPACE_BASE_URL/.test(html), + name: "home-v5 exposes separate ScratchNode and NodeBench base URLs", + detail: "PUBLIC_BASE_URL and WORKSPACE_BASE_URL present", + }); + + const hasPrivateHandoffContract = + /function\s+buildNodeBenchEventPrivateUrl\s*\(/.test(html) && + /WORKSPACE_BASE_URL[\s\S]{0,240}['"]\/events\/['"][\s\S]{0,240}['"]\/private\?source=scratchnode['"]/.test(html) && + /continuation=['"]?\s*\+\s*encodeURIComponent\(['"]private-notes['"]\)/.test(html) && + /publicArtifact=['"]?\s*\+\s*encodeURIComponent\(['"]event-wiki['"]\)/.test(html) && + /function\s+openNodeBenchPrivateHandoff\s*\(/.test(html); + addCheck({ + ok: hasPrivateHandoffContract, + name: "ScratchNode private handoff targets NodeBench event artifact", + plane: "nodebench-handoff", + detail: "WORKSPACE_BASE_URL /events/:id with continuation=private-notes and publicArtifact=event-wiki", + }); + if (!hasPrivateHandoffContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "nodebench-handoff", + title: "NodeBench private handoff URL contract is missing or incomplete", + path, + recommendation: + "Ensure ScratchNode uses WORKSPACE_BASE_URL /events/:eventId and includes continuation=private-notes plus publicArtifact=event-wiki.", + }); + } + + const hasPrivateAnchorContract = + /client\.onUpdate\(['"]notes:listMyAnchors['"][\s\S]{0,220}\{\s*ownerKey:\s*noteOwnerKey,\s*eventId\s*\}/i.test(html) && + /window\._sn_anchors_by_target\s*=\s*new Map\(\)/i.test(html) && + /window\._sn_anchors_by_note\s*=\s*new Map\(\)/i.test(html) && + /className\s*=\s*['"]sn-anchor-pin['"]/i.test(html) && + /data-anchor-id/i.test(html) && + /data-note-id/i.test(html) && + /window\._sn_pending_anchor/i.test(html) && + /client\.mutation\(['"]notes:createNoteAnchor['"]/i.test(html) && + /There is NO public broadcast of anchor data/i.test(html); + addCheck({ + ok: hasPrivateAnchorContract, + name: "home-v5 private note anchors are owner-scoped and preservable", + plane: "privacy", + detail: "listMyAnchors ownerKey subscription, sn-anchor-pin ids, pending-anchor create path", + }); + if (!hasPrivateAnchorContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "privacy", + title: "Private note anchor contract is missing or incomplete", + path, + recommendation: + "Ensure private note anchors render from owner-keyed listMyAnchors, expose note/anchor ids for verification, and create anchors through notes:createNoteAnchor only.", + }); + } + + const hasRoomWallArtifactContract = + /class=["']sn-pin["'][\s\S]{0,220}snWall\s*&&\s*window\.snWall\.pinAnswer/i.test(html) && + /window\.snSuggestFaq\s*&&\s*window\.snSuggestFaq/i.test(html) && + /window\.snPromoteFaq\s*&&\s*window\.snPromoteFaq/i.test(html) && + /className\s*=\s*['"]host-queue['"]/i.test(html) && + /Promote to FAQ/i.test(html) && + /className\s*=\s*['"]published-wiki-card['"]/i.test(html) && + /artifact\.published_event_wiki/i.test(html) && + /EventArchiveArtifact/i.test(html) && + /hostPromotedOnly:\s*true/i.test(html) && + /privateNotesExcluded:\s*true/i.test(html); + addCheck({ + ok: hasRoomWallArtifactContract, + name: "home-v5 room wall turns public answers into host-promoted wiki artifacts", + plane: "product-workflow", + detail: "pin answer + suggest FAQ + host queue promotion + published wiki artifact + private-note exclusion", + }); + if (!hasRoomWallArtifactContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "product-workflow", + title: "Room wall artifact contract is missing or incomplete", + path, + recommendation: + "Ensure public /ask answers can be pinned to the wall, suggested for FAQ, host-promoted, and compacted into an event-public wiki artifact that excludes private notes.", + }); + } + + const dailyBriefDeltaIndex = html.indexOf('class="daily-brief-delta"'); + const dailyBriefDeltaBlock = dailyBriefDeltaIndex >= 0 ? html.slice(dailyBriefDeltaIndex, dailyBriefDeltaIndex + 1800) : ""; + const hasDailyBriefDeltaContract = + /data-delta-source=["']event-artifact["']/i.test(dailyBriefDeltaBlock) && + /data-private-notes=["']workspace-only["']/i.test(dailyBriefDeltaBlock) && + /What changed/i.test(dailyBriefDeltaBlock) && + /Why it matters/i.test(dailyBriefDeltaBlock) && + /event wiki and public sources/i.test(dailyBriefDeltaBlock) && + /Private notes stay workspace-only/i.test(dailyBriefDeltaBlock); + addCheck({ + ok: hasDailyBriefDeltaContract, + name: "NodeBench Daily Brief delta explains changed event artifacts without public private notes", + plane: "nodebench-handoff", + detail: "what changed + why it matters + event artifact/wiki source + workspace-only private notes", + }); + if (!hasDailyBriefDeltaContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "nodebench-handoff", + title: "Daily Brief delta contract is missing or incomplete", + path, + recommendation: + "Ensure the NodeBench handoff explains what changed, why it matters, uses the event artifact/wiki as public source, and keeps private notes workspace-only.", + }); + } +} + +function scanBackendContracts() { + const events = readText(files.events); + const notes = readText(files.notes); + const users = readText(files.users); + + const contracts = [ + { + name: "events.ts documents public/private boundary", + ok: /only handles PUBLIC chat/i.test(events) && /Private notes/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "provider prompt forbids private notes", + ok: /Do not use or mention private notes/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "answer trace states private notes are excluded", + ok: /private notes excluded/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "semantic cache trace shows source reuse and privacy boundary", + ok: + /step:\s*["']semantic_cache_lookup["'][\s\S]{0,360}status:\s*["']ok["'][\s\S]{0,360}source bundle unchanged[\s\S]{0,180}private notes excluded/i.test(events) && + /cacheHit:\s*true/i.test(events) && + /externalSearches:\s*0/i.test(events) && + /computeCacheSkipReason/i.test(events) && + /Cached answer skipped/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "ask work uses idempotency and rate limit preparation", + ok: /reserveAskSlot|_reserveAskSlot/i.test(events) && /ASK_RATE_LIMIT_PER_MIN/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "normal sendMessage path is separate from askAgent", + ok: /export const sendMessage = mutation/i.test(events) && /export const askAgent = action/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "host-only wiki promotion/publish gate exists", + ok: /(?:const|function)\s+requireHost/i.test(events) && /promoteAnswerToFaq/i.test(events) && /publishWiki/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "private notes are owner-key scoped", + ok: /ownerKey/i.test(notes) && /createNote|listMyNotes/i.test(notes), + path: files.notes, + blocker: true, + }, + { + name: "ScratchNode sign-in user module exists", + ok: /Sign in to scratchnode\.live|magic/i.test(users), + path: files.users, + blocker: false, + }, + ]; + + for (const contract of contracts) { + addCheck({ + ok: contract.ok, + name: contract.name, + plane: "backend-contract", + detail: contract.path, + }); + if (!contract.ok) { + addFinding({ + severity: contract.blocker ? "blocker" : "warn", + safety: "human-gated", + plane: "backend-contract", + title: `Backend contract signal missing: ${contract.name}`, + path: contract.path, + recommendation: "Inspect the backend implementation and tests before launch.", + }); + } + } +} + +function scanPublicRepoReadiness() { + for (const relativePath of [ + files.exportScript, + files.goalLoopScript, + files.splitRunbook, + files.launchRunbook, + files.goalRunbook, + files.goalQueue, + files.scratchnodeGoal, + files.nodebenchGoal, + files.runtimeGoal, + files.demoQa, + files.license, + files.security, + files.contributing, + ]) { + checkRequiredFile(relativePath); + } + + const exportScript = readText(files.exportScript); + const splitRunbook = readText(files.splitRunbook); + const readme = readText(files.readme); + + addCheck({ + ok: /Explicit Exclusions/i.test(splitRunbook) && /convex\//i.test(splitRunbook), + name: "public split runbook documents monorepo exclusions", + plane: "public-repo", + detail: files.splitRunbook, + }); + addCheck({ + ok: /forbidden|sensitive|allowlist/i.test(exportScript), + name: "public export script scans allowlist/sensitive output", + plane: "public-repo", + detail: files.exportScript, + }); + addCheck({ + ok: /ScratchNode|NodeBench/i.test(readme), + name: "root README names product context", + plane: "public-repo", + detail: files.readme, + optional: true, + }); +} + +function scanGoalAutomationReadiness() { + const packageJson = readText("package.json"); + const goalRunbook = readText(files.goalRunbook); + const goalLoopScript = readText(files.goalLoopScript); + const launchRunbook = readText(files.launchRunbook); + + const checks = [ + { + name: "package exposes ScratchNode launch goal loop script", + ok: /"scratchnode:launch:goal"\s*:\s*"node scripts\/scratchnode\/runLaunchGoalLoop\.mjs"/i.test(packageJson), + path: "package.json", + blocker: true, + detail: "scratchnode:launch:goal", + }, + { + name: "goal loop runs housekeeping and launch interaction gates", + ok: /repo:housekeeping:check/i.test(goalLoopScript) && /scratchnode:launch:interactive/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "housekeeping + static/live/interactive launch checks", + }, + { + name: "goal loop writes durable .tmp report", + ok: /scratchnode-launch-goal-loop\.json/i.test(goalLoopScript) && /notifyRecommended/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: ".tmp/scratchnode-launch-goal-loop.json", + }, + { + name: "goal loop carries continuous development backlog", + ok: + /developmentBacklog/i.test(goalLoopScript) && + /nextDevelopmentCandidate/i.test(goalLoopScript) && + /safe-local-development/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "developmentBacklog + nextDevelopmentCandidate", + }, + { + name: "goal loop reads durable repo goal queue", + ok: /readGoalQueue/i.test(goalLoopScript) && /goalQueue/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "goals/**/*.md -> goalQueue", + }, + { + name: "goal loop ignores non-card Markdown queue docs", + ok: + /frontmatter\.id/i.test(goalLoopScript) && + /frontmatter\.status/i.test(goalLoopScript) && + /frontmatter\.mode/i.test(goalLoopScript) && + /\.filter\(Boolean\)/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "requires explicit goal-card frontmatter before backlog inclusion", + }, + { + name: "goal loop covers both ScratchNode and NodeBench improvement axes", + ok: /ScratchNode product workflow/i.test(goalLoopScript) && /NodeBench handoff/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "ScratchNode product workflow + NodeBench handoff", + }, + { + name: "goal loop preserves production mutation boundary", + ok: + /read-only against production/i.test(goalLoopScript) && + /sending chat|creating events|publishing wikis|mutating live user data/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "no live chat/event/wiki mutations", + }, + { + name: "goal runbook treats /goal as stop condition", + ok: /goal is not a normal prompt/i.test(goalRunbook) && /stop condition/i.test(goalRunbook), + path: files.goalRunbook, + blocker: true, + detail: files.goalRunbook, + }, + { + name: "goal runbook captures self-directed workflow pattern", + ok: + /batched issue queue/i.test(goalRunbook) && + /specialist passes/i.test(goalRunbook) && + /cost\/effort accounting/i.test(goalRunbook), + path: files.goalRunbook, + blocker: false, + detail: "batch queue + focused passes + cost accounting", + }, + { + name: "launch runbook includes goal-loop cron command", + ok: /scratchnode:launch:goal/i.test(launchRunbook), + path: files.launchRunbook, + blocker: false, + detail: files.launchRunbook, + }, + ]; + + for (const check of checks) { + addCheck({ + ok: check.ok, + name: check.name, + plane: "goal-automation", + detail: check.detail, + optional: !check.blocker, + }); + if (!check.ok) { + addFinding({ + severity: check.blocker ? "blocker" : "warn", + safety: "human-gated", + plane: "goal-automation", + title: `Goal automation signal missing: ${check.name}`, + path: check.path, + recommendation: "Restore the goal-loop contract before relying on unattended launch automation.", + }); + } + } +} + +async function fetchWithTimeout(url, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15_000); + const started = performance.now(); + try { + const response = await fetch(url, { + redirect: "follow", + headers: { + "user-agent": "nodebench-scratchnode-launch-scan/1.0", + ...(options.headers ?? {}), + }, + signal: controller.signal, + }); + const contentType = response.headers.get("content-type") ?? ""; + const body = options.body === false ? "" : await response.text(); + return { + ok: response.ok, + status: response.status, + url: response.url, + contentType, + body, + durationMs: performance.now() - started, + }; + } finally { + clearTimeout(timeout); + } +} + +async function runHttpCheck(name, url, validate, options = {}) { + try { + const result = await fetchWithTimeout(url, options); + const validation = validate(result); + addLiveCheck({ + ok: result.ok && validation.ok, + name, + url, + status: result.status, + durationMs: result.durationMs, + detail: validation.detail, + optional: options.optional, + }); + } catch (error) { + addLiveCheck({ + ok: false, + name, + url, + detail: error instanceof Error ? error.message : String(error), + optional: options.optional, + }); + } +} + +function headSignals(html) { + const head = html.match(//i)?.[0] ?? html.slice(0, 8000); + return { + head, + title: head.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? "", + canonical: head.match(/]*rel=["']canonical["'][^>]*>/i)?.[0] ?? "", + ogTitle: head.match(/]*property=["']og:title["'][^>]*>/i)?.[0] ?? "", + ogDescription: head.match(/]*property=["']og:description["'][^>]*>/i)?.[0] ?? "", + }; +} + +async function runLiveChecks() { + await runHttpCheck("scratchnode.live apex raw HTML", "https://scratchnode.live/", (result) => { + const signals = headSignals(result.body); + const ok = + /ScratchNode/i.test(signals.title) && + /scratchnode\.live/i.test(signals.canonical) && + /ScratchNode/i.test(signals.ogTitle) && + !/AI Infra Summit/i.test(signals.title + signals.ogTitle + signals.ogDescription + signals.canonical); + return { + ok, + detail: `title=${JSON.stringify(signals.title)}, canonical=${signals.canonical.slice(0, 140)}`, + }; + }); + + await runHttpCheck("scratchnode.live event route shell", "https://scratchnode.live/e/ai-infra-summit-2026", (result) => { + const signals = headSignals(result.body); + return { + ok: /ScratchNode/i.test(signals.title) && / { + const signals = headSignals(result.body); + return { + ok: /ScratchNode/i.test(signals.title) && !/demo_ver/i.test(result.url), + detail: `finalUrl=${result.url}, title=${JSON.stringify(signals.title)}`, + }; + }); + + await runHttpCheck("scratchnode.live demo route reachable", "https://scratchnode.live/demo_ver1", (result) => ({ + ok: /demoVerMatch|runDemoFull|ScratchNode/i.test(result.body), + detail: `bytes=${result.body.length}`, + })); + + await runHttpCheck("scratchnode public config endpoint", "https://scratchnode.live/api/scratchnode-config", (result) => { + try { + const json = JSON.parse(result.body); + const keys = Object.keys(json).sort(); + const hasOnlyPublicShape = + keys.includes("convexUrl") && + !keys.some((key) => /secret|token|key|password/i.test(key)); + return { ok: hasOnlyPublicShape, detail: `keys=${keys.join(",")}` }; + } catch { + return { ok: false, detail: "response is not JSON" }; + } + }); + + await runHttpCheck("scratchnode OG image", "https://scratchnode.live/og-scratchnode.png", (result) => ({ + ok: /image\/png/i.test(result.contentType) && result.body.length > 1000, + detail: `contentType=${result.contentType}, bytes=${result.body.length}`, + })); + + await runHttpCheck("scratchnode missing-room route shell", "https://scratchnode.live/e/zzz-does-not-exist-zzz", (result) => ({ + ok: /ScratchNode/i.test(headSignals(result.body).title) && / { + const signals = headSignals(result.body); + return { + ok: /NodeBench/i.test(signals.title) || /id=["']root["']/i.test(result.body), + detail: `finalUrl=${result.url}, title=${JSON.stringify(signals.title)}`, + }; + }); + + await runHttpCheck("www.nodebenchai.com apex", "https://www.nodebenchai.com/", (result) => { + const signals = headSignals(result.body); + return { + ok: /NodeBench/i.test(signals.title) || /id=["']root["']/i.test(result.body), + detail: `finalUrl=${result.url}, title=${JSON.stringify(signals.title)}`, + }; + }); + + await runHttpCheck("nodebenchai.com scratchnode-events route", "https://nodebenchai.com/scratchnode-events", (result) => ({ + ok: /id=["']root["']/i.test(result.body) && !/sidecar event rooms with memory/i.test(headSignals(result.body).title), + detail: `finalUrl=${result.url}, title=${JSON.stringify(headSignals(result.body).title)}`, + })); +} + +async function runInteractiveChecks() { + let chromium; + try { + ({ chromium } = await import("playwright")); + } catch (error) { + addInteractiveCheck({ + ok: false, + name: "Playwright import", + url: "local", + detail: error instanceof Error ? error.message : String(error), + optional: true, + }); + return; + } + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + userAgent: "nodebench-scratchnode-launch-scan/interactive", + }); + + async function pageCheck(name, url, validate) { + const page = await context.newPage(); + const consoleErrors = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + const started = performance.now(); + try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + const result = await validate(page, consoleErrors); + addInteractiveCheck({ + ok: !!result.ok, + name, + url, + durationMs: performance.now() - started, + detail: result.detail, + }); + } catch (error) { + addInteractiveCheck({ + ok: false, + name, + url, + durationMs: performance.now() - started, + detail: error instanceof Error ? error.message : String(error), + }); + } finally { + await page.close().catch(() => {}); + } + } + + await pageCheck("scratchnode apex interactive landing", "https://scratchnode.live/", async (page, consoleErrors) => { + await page.waitForSelector("body", { timeout: 10_000 }); + const data = await page.evaluate(() => ({ + title: document.title, + pageMode: document.body.getAttribute("data-page-mode"), + hasJoinInput: !!document.querySelector("#landing-code, #ci"), + buttonCount: document.querySelectorAll("button").length, + })); + return { + ok: /ScratchNode/i.test(data.title) && data.hasJoinInput && data.buttonCount > 0, + detail: `title=${JSON.stringify(data.title)}, pageMode=${data.pageMode}, buttons=${data.buttonCount}, consoleErrors=${consoleErrors.length}`, + }; + }); + + await pageCheck("scratchnode event route interactive", "https://scratchnode.live/e/ai-infra-summit-2026?demo=1", async (page) => { + await page.waitForFunction(() => document.body.getAttribute("data-page-mode") === "event", null, { timeout: 15_000 }); + const data = await page.evaluate(() => ({ + pageMode: document.body.getAttribute("data-page-mode"), + live: document.body.getAttribute("data-sn-live"), + fullDemoAllowed: globalThis.shouldRunScratchNodeFullDemo?.(), + composerDisabled: document.querySelector("#ci")?.hasAttribute("disabled") ?? null, + hasAskHint: /\/ask/i.test(document.body.textContent ?? ""), + })); + return { + ok: data.pageMode === "event" && data.fullDemoAllowed === false && data.hasAskHint, + detail: JSON.stringify(data), + }; + }); + + await pageCheck("scratchnode event workflow affordances", "https://scratchnode.live/e/ai-infra-summit-2026", async (page) => { + await page.waitForFunction(() => document.body.getAttribute("data-page-mode") === "event", null, { timeout: 15_000 }); + const data = await page.evaluate(() => { + const bodyText = document.body.textContent ?? ""; + const buttonsAndLinks = [...document.querySelectorAll("button, a")] + .map((node) => node.textContent?.replace(/\s+/g, " ").trim() ?? "") + .filter(Boolean); + const rowTexts = [...document.querySelectorAll(".row-text")] + .map((node) => node.textContent?.trim() ?? "") + .filter(Boolean); + const answerQuestions = [...document.querySelectorAll(".ans-q")] + .map((node) => node.textContent?.trim() ?? "") + .filter(Boolean); + const answerTexts = [...document.querySelectorAll(".ans")] + .map((node) => node.textContent?.replace(/\s+/g, " ").trim() ?? "") + .filter(Boolean); + const askQuestions = rowTexts + .filter((text) => /^\/ask\b/i.test(text)) + .map((text) => text.replace(/^\/ask\b/i, "").trim().replace(/[?.!]+$/, "").toLowerCase()) + .filter(Boolean); + return { + composerPlaceholder: document.querySelector("#ci")?.getAttribute("placeholder") ?? "", + hasAskParentRow: rowTexts.some((text) => /^\/ask\b/i.test(text)), + hasNestedAnswer: askQuestions.some((question) => + answerQuestions.some((answerQuestion) => answerQuestion.replace(/[?.!]+$/, "").toLowerCase() === question), + ), + hasPublicTraceBoundary: answerTexts.some((text) => /no private notes|private notes excluded/i.test(text)), + hasFaqSuggestion: buttonsAndLinks.some((text) => /suggest for faq/i.test(text)), + hasWikiContinuation: buttonsAndLinks.some((text) => /open wiki|view in wiki/i.test(text)), + hasPrivateNotesAffordance: !!document.querySelector("#lock") && /My private notes|private notes/i.test(bodyText), + }; + }); + const ok = + /\/ask/i.test(data.composerPlaceholder) && + data.hasAskParentRow && + data.hasNestedAnswer && + data.hasPublicTraceBoundary && + data.hasFaqSuggestion && + data.hasWikiContinuation && + data.hasPrivateNotesAffordance; + return { + ok, + detail: JSON.stringify(data), + }; + }); + + await pageCheck("scratchnode event interactive components", "https://scratchnode.live/e/ai-infra-summit-2026", async (page) => { + await page.waitForFunction(() => typeof globalThis.openWiki === "function", null, { timeout: 15_000 }); + const data = await page.evaluate(async () => { + const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); + const readSheetTitle = () => document.querySelector("#sheet-title")?.textContent?.trim() ?? ""; + const readSheetText = () => document.querySelector("#sheet-content")?.textContent?.trim() ?? ""; + const close = () => { + if (typeof globalThis.closeSheet === "function") globalThis.closeSheet(); + }; + + const functions = [ + "openWiki", + "openPeople", + "openShare", + "openNotes", + "toggleLock", + "openModePicker", + "openCaptureLevelPicker", + ].filter((name) => typeof globalThis[name] === "function"); + + globalThis.openWiki(); + await sleep(250); + const wikiTitle = readSheetTitle(); + const wikiText = readSheetText(); + close(); + + globalThis.openPeople(); + await sleep(250); + const peopleTitle = readSheetTitle(); + const peopleText = readSheetText(); + close(); + + globalThis.openShare(); + await sleep(250); + const shareTitle = readSheetTitle(); + const shareText = readSheetText(); + const shareUrl = document.querySelector(".share-url-box code")?.textContent?.trim() ?? ""; + const shareCopyButtonText = document.querySelector(".share-url-box button")?.textContent?.trim() ?? ""; + const shareHasPublicEventUrl = /https:\/\/scratchnode\.live\/e\/ai-infra-summit-2026/i.test(shareUrl || shareText); + const shareHasCopyAction = /copy/i.test(shareCopyButtonText); + const shareHasRoomCode = /Live room code\s+ORBITAL|code\s+ORBITAL|ORBITAL/i.test(shareText); + close(); + + globalThis.openNotes(); + await sleep(250); + const notesText = readSheetText(); + close(); + + const lock = document.querySelector("#lock"); + const beforePrivate = lock?.getAttribute("data-on"); + globalThis.toggleLock(); + await sleep(50); + const afterPrivate = lock?.getAttribute("data-on"); + globalThis.toggleLock(); + + const wallEl = document.querySelector("#sn-wall"); + const wallControllerReady = !!globalThis.snWall && typeof globalThis.snWall.show === "function"; + const wallTabVisible = [...document.querySelectorAll("[data-rt]")] + .some((node) => /wall/i.test(node.textContent ?? "")); + if (wallControllerReady) { + globalThis.snWall.show("wall"); + await sleep(100); + } + const wallShown = + wallEl?.getAttribute("data-on") === "true" && + wallEl?.getAttribute("aria-hidden") === "false"; + if (wallControllerReady) { + globalThis.snWall.show("chat"); + await sleep(100); + } + const wallHidden = + wallEl?.getAttribute("data-on") === "false" && + wallEl?.getAttribute("aria-hidden") === "true"; + + return { + functions, + wikiTitle, + wikiText: wikiText.slice(0, 160), + peopleTitle, + peopleText: peopleText.slice(0, 160), + shareTitle, + shareUrl, + shareCopyButtonText, + shareText: shareText.slice(0, 160), + shareHasPublicEventUrl, + shareHasCopyAction, + shareHasRoomCode, + notesText: notesText.slice(0, 160), + beforePrivate, + afterPrivate, + wallControllerReady, + wallTabVisible, + wallShown, + wallHidden, + }; + }); + const ok = + data.functions.length >= 7 && + /Wiki/i.test(data.wikiTitle) && + /People/i.test(data.peopleTitle) && + /Share/i.test(data.shareTitle) && + data.shareHasPublicEventUrl && + data.shareHasCopyAction && + data.shareHasRoomCode && + /private|notes/i.test(data.notesText) && + data.beforePrivate !== data.afterPrivate && + data.wallControllerReady && + data.wallTabVisible && + data.wallShown && + data.wallHidden; + return { + ok, + detail: JSON.stringify(data), + }; + }); + + await pageCheck("scratchnode demo route interactive", "https://scratchnode.live/demo_ver1?demoSpeed=instant", async (page) => { + await page.waitForFunction(() => document.body.getAttribute("data-page-mode") === "demo", null, { timeout: 15_000 }); + await page.waitForTimeout(1000); + const data = await page.evaluate(() => ({ + pageMode: document.body.getAttribute("data-page-mode"), + fullDemoAllowed: globalThis.shouldRunScratchNodeFullDemo?.(), + demoLogLength: Array.isArray(globalThis._demo_log) ? globalThis._demo_log.length : 0, + buttonCount: document.querySelectorAll("button").length, + })); + return { + ok: data.pageMode === "demo" && data.fullDemoAllowed === true && data.buttonCount > 0, + detail: JSON.stringify(data), + }; + }); + + await pageCheck("nodebench apex interactive", "https://nodebenchai.com/", async (page) => { + await page.waitForSelector("body", { timeout: 15_000 }); + const data = await page.evaluate(() => ({ + title: document.title, + hasRoot: !!document.querySelector("#root"), + bodyText: (document.body.textContent ?? "").slice(0, 300), + })); + return { + ok: /NodeBench/i.test(data.title) || data.hasRoot, + detail: `title=${JSON.stringify(data.title)}, hasRoot=${data.hasRoot}`, + }; + }); + + await pageCheck("nodebench scratchnode-events interactive", "https://nodebenchai.com/scratchnode-events", async (page) => { + await page.waitForSelector("body", { timeout: 15_000 }); + await page.waitForTimeout(1200); + const data = await page.evaluate(() => ({ + title: document.title, + hasRoot: !!document.querySelector("#root"), + text: (document.body.textContent ?? "").slice(0, 800), + scratchnodeActions: [...document.querySelectorAll("a,button")] + .map((el) => ({ + text: el.textContent?.trim() ?? "", + href: el instanceof HTMLAnchorElement ? el.href : "", + })) + .filter((item) => /ScratchNode|event|open|join/i.test(`${item.text} ${item.href}`)) + .slice(0, 12), + })); + return { + ok: data.hasRoot && /ScratchNode|events|NodeBench/i.test(data.text + data.title) && data.scratchnodeActions.length > 0, + detail: `title=${JSON.stringify(data.title)}, hasRoot=${data.hasRoot}, actions=${data.scratchnodeActions.length}`, + }; + }); + + await pageCheck("nodebench scratchnode-events handoff empty-state contract", "https://nodebenchai.com/scratchnode-events", async (page) => { + await page.waitForSelector("body", { timeout: 15_000 }); + await page.waitForTimeout(1200); + const data = await page.evaluate(() => { + const text = (document.body.textContent ?? "").replace(/\s+/g, " ").trim(); + const scratchnodeLinks = [...document.querySelectorAll("a")] + .map((link) => ({ + text: link.textContent?.replace(/\s+/g, " ").trim() ?? "", + href: link.href, + })) + .filter((link) => /scratchnode\.live/i.test(link.href)); + return { + title: document.title, + hasRoot: !!document.querySelector("#root"), + hasHandoffTitle: /ScratchNode\s*(?:->|→)\s*NodeBench/i.test(text), + hasNoSessionState: /No ScratchNode session/i.test(text), + hasPrivateNotesContinuation: /private notes will appear/i.test(text), + scratchnodeLinks, + }; + }); + const ok = + /NodeBench/i.test(data.title) && + data.hasRoot && + data.hasHandoffTitle && + data.hasNoSessionState && + data.hasPrivateNotesContinuation && + data.scratchnodeLinks.some((link) => link.href === "https://scratchnode.live/" || link.href.startsWith("https://scratchnode.live/?")); + return { + ok, + detail: JSON.stringify({ + title: data.title, + hasRoot: data.hasRoot, + hasHandoffTitle: data.hasHandoffTitle, + hasNoSessionState: data.hasNoSessionState, + hasPrivateNotesContinuation: data.hasPrivateNotesContinuation, + scratchnodeLinks: data.scratchnodeLinks.slice(0, 3), + }), + }; + }); + + await browser.close(); +} + +function summarize() { + const requiredStaticFailures = staticChecks.filter((check) => !check.ok && !check.optional); + const blockerFindings = findings.filter((finding) => finding.severity === "blocker"); + const warnFindings = findings.filter((finding) => finding.severity === "warn"); + const liveFailures = liveChecks.filter((check) => !check.ok && !check.optional); + const interactiveFailures = interactiveChecks.filter((check) => !check.ok && !check.optional); + const passed = + requiredStaticFailures.length === 0 && + blockerFindings.length === 0 && + liveFailures.length === 0 && + interactiveFailures.length === 0 && + (!shouldFailOnWarn || warnFindings.length === 0); + + return { + passed, + staticPassed: requiredStaticFailures.length === 0 && blockerFindings.length === 0, + livePassed: liveFailures.length === 0, + interactivePassed: interactiveFailures.length === 0, + requiredStaticFailures: requiredStaticFailures.length, + blockers: blockerFindings.length, + warnings: warnFindings.length, + autoSafeFindings: findings.filter((finding) => finding.safety === "auto").length, + humanGatedFindings: findings.filter((finding) => finding.safety === "human-gated").length, + liveFailures: liveFailures.length, + interactiveFailures: interactiveFailures.length, + staticChecks: staticChecks.length, + liveChecks: liveChecks.length, + interactiveChecks: interactiveChecks.length, + }; +} + +async function main() { + checkRequiredFile(files.homeV5); + checkRequiredFile(files.vercel); + checkRequiredFile(files.scratchnodeConfig); + scanHomeV5(); + scanBackendContracts(); + scanPublicRepoReadiness(); + scanGoalAutomationReadiness(); + + if (shouldRunLive) await runLiveChecks(); + if (shouldRunInteractive) await runInteractiveChecks(); + + const report = { + generatedAt: new Date().toISOString(), + repo: repoRoot, + modes: { + static: true, + live: shouldRunLive, + interactive: shouldRunInteractive, + failOnWarn: shouldFailOnWarn, + }, + summary: summarize(), + findings, + staticChecks, + liveChecks, + interactiveChecks, + }; + + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`); + + if (shouldPrintJson) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log( + `ScratchNode launch scan: ${report.summary.passed ? "PASS" : "FAIL"} ` + + `(blockers=${report.summary.blockers}, warnings=${report.summary.warnings}, ` + + `liveFailures=${report.summary.liveFailures}, interactiveFailures=${report.summary.interactiveFailures})`, + ); + console.log(`Report: ${outPath}`); + for (const finding of findings.slice(0, 12)) { + const where = finding.line ? `${finding.path}:${finding.line}` : finding.path; + console.log(`- [${finding.severity}/${finding.safety}] ${where} ${finding.title}`); + } + if (findings.length > 12) { + console.log(`- ... ${findings.length - 12} more finding(s) in report`); + } + } + + if (!report.summary.passed) process.exitCode = 1; +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exitCode = 1; +}); From 35813b32a02d54ae77542d3a951a3113de6406cf Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 2 Jun 2026 14:15:25 -0700 Subject: [PATCH 02/91] Add ScratchNode public room discovery --- convex/events.runtime-boundary.test.ts | 33 +++ convex/events.ts | 114 ++++++++++- convex/schema/eventsSchema.ts | 10 +- public/proto/home-v5.html | 192 +++++++++++++++++- .../scratchnode-live-route-honesty.spec.ts | 55 +++++ 5 files changed, 390 insertions(+), 14 deletions(-) diff --git a/convex/events.runtime-boundary.test.ts b/convex/events.runtime-boundary.test.ts index a4613849..736483b3 100644 --- a/convex/events.runtime-boundary.test.ts +++ b/convex/events.runtime-boundary.test.ts @@ -93,6 +93,39 @@ describe("scratchnode public runtime boundaries", () => { expect(getMyEvents).toContain("hosted"); }); + it("keeps landing stats and public room discovery bounded and activity-based", () => { + const getLandingStats = functionBlock("getLandingStats", "query"); + const listPublicRooms = functionBlock("listPublicRooms", "query"); + const createEvent = functionBlock("createEvent"); + + expect(eventsSchemaSource).toContain("publicDiscoverable"); + expect(eventsSchemaSource).toContain("joinPolicy"); + expect(eventsSchemaSource).toContain("lastActivityAt"); + expect(eventsSchemaSource).toContain("by_public_status_startedAt"); + + expect(getLandingStats).toContain("MAX_LANDING_STATS_SCAN"); + expect(getLandingStats).toContain("PUBLIC_ROOM_ACTIVE_WINDOW_MS"); + expect(getLandingStats).toContain("getEventActivityAt"); + + expect(listPublicRooms).toContain("MAX_PUBLIC_ROOM_CARDS"); + expect(listPublicRooms).toContain("MAX_PUBLIC_ROOM_CANDIDATES"); + expect(listPublicRooms).toContain('q.eq("publicDiscoverable", true)'); + expect(listPublicRooms).toContain("activeSessionsCapped"); + + expect(createEvent).toContain("publicDiscoverable: args.publicDiscoverable === true"); + expect(createEvent).toContain('joinPolicy: args.joinPolicy || "open"'); + }); + + it("does not let passive heartbeats keep public room listings warm", () => { + const joinEvent = functionBlock("joinEvent"); + const sendMessage = functionBlock("sendMessage"); + const heartbeat = functionBlock("heartbeat"); + + expect(joinEvent).toContain("lastActivityAt: now"); + expect(sendMessage).toContain("lastActivityAt: now"); + expect(heartbeat).not.toContain("lastActivityAt"); + }); + it("keeps host claim idempotent for the same owner key before rejecting claimed rooms", () => { const claimHost = functionBlock("claimHost"); const ownerLookup = claimHost.indexOf('withIndex("by_event_owner"'); diff --git a/convex/events.ts b/convex/events.ts index 371001ec..027069d3 100644 --- a/convex/events.ts +++ b/convex/events.ts @@ -6,6 +6,8 @@ * * Public API surface (called from public/proto/home-v5.html): * - getEventBySlug({ slug }) + * - getLandingStats() // bounded landing social proof + * - listPublicRooms({ limit? }) // opt-in, recently active public directory * - getMessages({ eventId, limit? }) // realtime subscription * - getMembers({ eventId }) // realtime subscription * - getMyEvents({ sessionId, ownerKey, limit? }) // lightweight account/event state @@ -73,6 +75,10 @@ const ASK_RATE_LIMIT_PER_MIN = 12; const ASK_RATE_WINDOW_MS = 60_000; const MAX_ANSWER_LIMIT = 100; const MAX_MY_EVENTS_LIMIT = 50; +const MAX_PUBLIC_ROOM_CARDS = 8; +const MAX_PUBLIC_ROOM_CANDIDATES = 40; +const MAX_PUBLIC_ROOM_ACTIVE_MEMBERS = 50; +const PUBLIC_ROOM_ACTIVE_WINDOW_MS = 30 * 60 * 1000; const MAX_WIKI_ANSWERS = 20; // Phase 4 raised this from 80 -> 120 to fit HMAC-signed host tokens // (hk1:::: ~ 80-90 chars). @@ -102,6 +108,10 @@ const isLikelyValidEmail = (value: string | undefined): value is string => { return EMAIL_REGEX.test(trimmed); }; +function getEventActivityAt(event: { startedAt: number; lastActivityAt?: number }): number { + return event.lastActivityAt ?? event.startedAt; +} + const DEMO_SOURCES = [ { uri: "transcript://ai-infra-summit-2026/mcp-auth-panel", @@ -832,17 +842,19 @@ const MAX_LANDING_STATS_SCAN = 5000; * * Honesty (agentic_reliability HONEST_SCORES): every number is a real row count. * No hardcoded floor, no inflation. `roomsCreated` counts every room ever made - * (ended rooms keep their row); `liveNow` counts rooms still open (status=live). - * When the backend is unreachable the landing HIDES the stat entirely rather - * than render a fabricated number (the client enforces the hide). + * (ended rooms keep their row); `liveNow` counts open rooms with recent join or + * chat activity, so stale test rooms fall out without destructive cleanup. When + * the backend is unreachable the landing HIDES the stat entirely rather than + * render a fabricated number (the client enforces the hide). */ export const getLandingStats = query({ args: {}, handler: async (ctx) => { const rows = await ctx.db.query("liveEvents").take(MAX_LANDING_STATS_SCAN); + const activeCutoff = Date.now() - PUBLIC_ROOM_ACTIVE_WINDOW_MS; let liveNow = 0; for (const row of rows) { - if (row.status === "live") liveNow += 1; + if (row.status === "live" && getEventActivityAt(row) >= activeCutoff) liveNow += 1; } return { roomsCreated: rows.length, @@ -852,6 +864,72 @@ export const getLandingStats = query({ }, }); +/** + * Public room directory for the apex landing. + * + * Cost and privacy rules: + * - Opt-in only (`publicDiscoverable === true`); old/code-only/test rooms stay hidden. + * - Recently active only; an idle tab heartbeat does not refresh lastActivityAt. + * - Bounded candidate scan and bounded per-card presence count. + */ +export const listPublicRooms = query({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, { limit }) => { + const safeLimit = Math.min(Math.max(limit ?? 4, 1), MAX_PUBLIC_ROOM_CARDS); + const activeCutoff = Date.now() - PUBLIC_ROOM_ACTIVE_WINDOW_MS; + const presenceCutoff = Date.now() - PRESENCE_TTL_MS; + const rows = await ctx.db + .query("liveEvents") + .withIndex("by_public_status_startedAt", (q) => + q.eq("publicDiscoverable", true).eq("status", "live"), + ) + .order("desc") + .take(MAX_PUBLIC_ROOM_CANDIDATES); + + const rooms: Array<{ + eventId: string; + slug: string; + name: string; + roomCode: string; + startedAt: number; + lastActivityAt: number; + activeSessions: number; + activeSessionsCapped: boolean; + joinPolicy: "open" | "request"; + }> = []; + + for (const event of rows) { + const lastActivityAt = getEventActivityAt(event); + if (lastActivityAt < activeCutoff) continue; + const members = await ctx.db + .query("liveEventMembers") + .withIndex("by_event_lastSeen", (q) => + q.eq("eventId", event._id).gte("lastSeenAt", presenceCutoff), + ) + .take(MAX_PUBLIC_ROOM_ACTIVE_MEMBERS + 1); + rooms.push({ + eventId: String(event._id), + slug: event.slug, + name: event.name, + roomCode: event.roomCode, + startedAt: event.startedAt, + lastActivityAt, + activeSessions: Math.min(members.length, MAX_PUBLIC_ROOM_ACTIVE_MEMBERS), + activeSessionsCapped: members.length > MAX_PUBLIC_ROOM_ACTIVE_MEMBERS, + joinPolicy: event.joinPolicy ?? "open", + }); + if (rooms.length >= safeLimit) break; + } + + return { + rooms, + activeWindowMs: PUBLIC_ROOM_ACTIVE_WINDOW_MS, + }; + }, +}); + /** * Realtime message stream. The Convex client re-runs this on every change. * Ordered ascending by createdAt so the latest is last (matches UI append order). @@ -1394,6 +1472,8 @@ export const createEvent = mutation({ roomCode: v.optional(v.string()), agendaText: v.optional(v.string()), status: v.optional(v.union(v.literal("draft"), v.literal("live"))), + publicDiscoverable: v.optional(v.boolean()), + joinPolicy: v.optional(v.union(v.literal("open"), v.literal("request"))), }, handler: async (ctx, args) => { // Fail before writing if production host-token env is missing. @@ -1505,7 +1585,10 @@ export const createEvent = mutation({ name: title, roomCode, status, + publicDiscoverable: args.publicDiscoverable === true, + joinPolicy: args.joinPolicy || "open", startedAt: now, + lastActivityAt: now, }); const safeName = (args.displayName || "Host").slice(0, MAX_DISPLAY_NAME).trim() || "Host"; @@ -1552,6 +1635,8 @@ export const createEvent = mutation({ name: title, roomCode, status, + publicDiscoverable: args.publicDiscoverable === true, + joinPolicy: args.joinPolicy || "open", ownerKey, hostId, sourceId, @@ -1566,8 +1651,10 @@ export const updateEvent = mutation({ title: v.optional(v.string()), roomCode: v.optional(v.string()), status: v.optional(v.union(v.literal("draft"), v.literal("live"))), + publicDiscoverable: v.optional(v.boolean()), + joinPolicy: v.optional(v.union(v.literal("open"), v.literal("request"))), }, - handler: async (ctx, { eventId, ownerKey, title, roomCode, status }) => { + handler: async (ctx, { eventId, ownerKey, title, roomCode, status, publicDiscoverable, joinPolicy }) => { await requireHost(ctx, eventId, ownerKey); const event = await ctx.db.get(eventId); if (!event) { @@ -1594,9 +1681,17 @@ export const updateEvent = mutation({ if (status !== undefined) { patch.status = status; if (status === "live" && event.status === "draft") { - patch.startedAt = Date.now(); + const now = Date.now(); + patch.startedAt = now; + patch.lastActivityAt = now; } } + if (publicDiscoverable !== undefined) { + patch.publicDiscoverable = publicDiscoverable; + } + if (joinPolicy !== undefined) { + patch.joinPolicy = joinPolicy; + } if (roomCode !== undefined) { const nextRoomCode = normalizeRequestedRoomCode(roomCode); if (!nextRoomCode || !isRoomCodeShape(nextRoomCode)) { @@ -1631,6 +1726,8 @@ export const updateEvent = mutation({ name: updated?.name ?? event.name, roomCode: updated?.roomCode ?? event.roomCode, status: updated?.status ?? event.status, + publicDiscoverable: updated?.publicDiscoverable ?? event.publicDiscoverable ?? false, + joinPolicy: updated?.joinPolicy ?? event.joinPolicy ?? "open", }; }, }); @@ -1797,6 +1894,7 @@ export const joinEvent = mutation({ lastSeenAt: now, }); } + await ctx.db.patch(event._id, { lastActivityAt: now }); return { eventId: event._id, @@ -1906,6 +2004,7 @@ export const sendMessage = mutation({ // Bump presence as a side effect of sending (saves a heartbeat round trip). await ctx.db.patch(member._id, { lastSeenAt: now }); + await ctx.db.patch(args.eventId, { lastActivityAt: now }); return { messageId, createdAt: now }; }, @@ -2885,7 +2984,10 @@ export const ensureDemoEvent = mutation({ name: "AI Infra Summit", roomCode: "ORBITAL", status: "live", + publicDiscoverable: false, + joinPolicy: "open", startedAt: Date.now(), + lastActivityAt: Date.now(), }); const sourcesInserted = await ensureDemoSourcesForEvent(ctx, id); return { eventId: id, created: true, sourcesInserted }; diff --git a/convex/schema/eventsSchema.ts b/convex/schema/eventsSchema.ts index f1e8af48..3de022b1 100644 --- a/convex/schema/eventsSchema.ts +++ b/convex/schema/eventsSchema.ts @@ -30,11 +30,19 @@ export const liveEvents = defineTable({ v.literal("live"), v.literal("ended"), ), + publicDiscoverable: v.optional(v.boolean()), // opt-in listing on the apex landing + joinPolicy: v.optional(v.union( + v.literal("open"), + v.literal("request"), + )), startedAt: v.number(), + lastActivityAt: v.optional(v.number()), // create/join/message activity; heartbeat does not keep listings warm endedAt: v.optional(v.number()), }) .index("by_slug", ["slug"]) - .index("by_roomCode", ["roomCode"]); + .index("by_roomCode", ["roomCode"]) + .index("by_status_startedAt", ["status", "startedAt"]) + .index("by_public_status_startedAt", ["publicDiscoverable", "status", "startedAt"]); // ------------------------------------------------------------------ // liveEventMembers — anonymous presence per event diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 479fe5ce..c5fad9d4 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -1681,6 +1681,10 @@ body[data-page-mode="landing"] .h-code, body[data-page-mode="landing"] .h-live, body[data-page-mode="landing"] #live-assist-rail, +body[data-page-mode="landing"] #live-assist-sheet, +body[data-page-mode="landing"] #live-assist-scrim, +body[data-page-mode="landing"] #sheet, +body[data-page-mode="landing"] #sheet-scrim, body[data-page-mode="landing"] .welcome { display: none !important; } @@ -1897,6 +1901,23 @@ display: flex; gap: 8px; } +.landing-discovery-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + max-width: 420px; + font-family: var(--ui); + font-size: 12px; + color: var(--ink-muted); + text-align: left; + cursor: pointer; +} +.landing-discovery-toggle input { + width: 14px; + height: 14px; + accent-color: var(--accent); +} .landing-create input { font-family: var(--ui); font-size: 15px; @@ -1914,6 +1935,14 @@ box-shadow: 0 0 0 3px rgba(217,119,87,.18); } .landing-create input::placeholder { color: var(--ink-faint); } +.landing-create .landing-discovery-toggle input { + flex: 0 0 auto; + width: 14px; + height: 14px; + padding: 0; + border: 0; + background: transparent; +} /* Outline-accent so "Join" stays the single primary CTA; Create is clearly available but secondary in the visual hierarchy. */ .landing-create button { @@ -1946,6 +1975,79 @@ } .landing-create-status[data-state="busy"] { color: var(--accent); } .landing-create-status[data-state="error"] { color: #d98b7a; } +.landing-public { + display: none; + width: 100%; + max-width: 560px; + flex-direction: column; + gap: 10px; + margin-top: 2px; +} +.landing-public[data-visible="true"] { display: flex; } +.landing-public-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + font-family: var(--ui); +} +.landing-public-head b { + font-size: 12px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--ink-muted); +} +.landing-public-head span { + font-size: 12px; + color: var(--ink-faint); +} +.landing-public-list { + display: grid; + gap: 8px; +} +.landing-room-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 12px 12px 12px 14px; + border: 1px solid rgba(255,255,255,.1); + border-radius: 8px; + background: rgba(255,255,255,.045); + text-align: left; +} +.landing-room-title { + font-family: var(--ui); + font-size: 14px; + font-weight: 700; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.landing-room-meta { + margin-top: 3px; + font-family: var(--ui); + font-size: 12px; + color: var(--ink-faint); +} +.landing-room-join { + font-family: var(--ui); + font-size: 12px; + font-weight: 700; + color: var(--accent); + text-decoration: none; + border: 1px solid rgba(217,119,87,.65); + border-radius: 7px; + padding: 8px 10px; + white-space: nowrap; +} +.landing-room-join:hover, +.landing-room-join:focus-visible { + background: var(--accent); + color: #fff; + outline: none; +} @media (max-width: 600px) { .landing-hero h1 { font-size: 22px; } .landing-hero p { font-size: 14px; } @@ -1953,6 +2055,8 @@ .landing-join button { width: 100%; } .landing-create-sub { flex-direction: column; } .landing-create button { width: 100%; } + .landing-room-card { grid-template-columns: 1fr; } + .landing-room-join { text-align: center; } .landing-pulse__big { font-size: 48px; } .landing-pulse__suffix { font-size: 30px; } } @@ -1961,6 +2065,7 @@ .landing-join button, .landing-create input, .landing-create button, + .landing-room-join, .landing-secondary { transition: none; } .landing-pulse, .landing-pulse__dot { animation: none; } @@ -2062,10 +2167,12 @@ try { ev.preventDefault(); } catch (e) {} var nameEl = document.getElementById('landing-create-name'); var codeEl = document.getElementById('landing-create-code'); + var discoverEl = document.getElementById('landing-create-public'); var btn = document.getElementById('landing-create-btn'); var statusEl = document.getElementById('landing-create-status'); var title = String((nameEl && nameEl.value) || '').trim(); var roomCode = String((codeEl && codeEl.value) || '').trim(); + var publicDiscoverable = !!(discoverEl && discoverEl.checked); function setStatus(msg, state) { if (!statusEl) return; @@ -2129,6 +2236,8 @@ displayName: displayName, roomCode: roomCode || undefined, status: 'live', + publicDiscoverable: publicDiscoverable, + joinPolicy: 'open', }); } catch (e) { try { client.close && client.close(); } catch (_) {} @@ -2189,21 +2298,30 @@ if (!wrap || !numEl || !liveEl) return; var reduce = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); var shownRooms = 0; + var shownLive = 0; var raf = null; - function animateTo(target) { + function animatePair(roomsTarget, liveTarget) { if (raf) { cancelAnimationFrame(raf); raf = null; } - if (reduce || shownRooms === target) { numEl.textContent = _snFmtStat(target); shownRooms = target; return; } - var from = shownRooms; + if (reduce || (shownRooms === roomsTarget && shownLive === liveTarget)) { + numEl.textContent = _snFmtStat(roomsTarget); + liveEl.textContent = _snFmtStat(liveTarget); + shownRooms = roomsTarget; + shownLive = liveTarget; + return; + } + var fromR = shownRooms; + var fromL = shownLive; var start = null; var dur = 750; function step(ts) { if (start === null) start = ts; var p = Math.min(1, (ts - start) / dur); var eased = 1 - Math.pow(1 - p, 3); - numEl.textContent = _snFmtStat(Math.round(from + (target - from) * eased)); + numEl.textContent = _snFmtStat(Math.round(fromR + (roomsTarget - fromR) * eased)); + liveEl.textContent = _snFmtStat(Math.round(fromL + (liveTarget - fromL) * eased)); if (p < 1) { raf = requestAnimationFrame(step); } - else { raf = null; shownRooms = target; } + else { raf = null; shownRooms = roomsTarget; shownLive = liveTarget; } } raf = requestAnimationFrame(step); } @@ -2214,8 +2332,54 @@ if (rooms < 1) { wrap.hidden = true; return; } // honest: hide empty, never fake wrap.hidden = false; if (sufEl) sufEl.textContent = stats.capped ? '+' : ''; - liveEl.textContent = _snFmtStat(Math.max(0, stats.liveNow || 0)); - animateTo(rooms); + animatePair(rooms, Math.max(0, stats.liveNow || 0)); + } + + function roomUrl(room) { + var slug = room && (room.slug || room.roomCode); + return '/e/' + encodeURIComponent(String(slug || '').toLowerCase()); + } + + function renderPublicRooms(payload) { + var section = document.getElementById('landing-public'); + var list = document.getElementById('landing-public-list'); + var countEl = document.getElementById('landing-public-count'); + if (!section || !list) return; + var rooms = payload && Array.isArray(payload.rooms) ? payload.rooms : []; + list.innerHTML = ''; + if (!rooms.length) { + section.removeAttribute('data-visible'); + if (countEl) countEl.textContent = ''; + return; + } + section.setAttribute('data-visible', 'true'); + if (countEl) countEl.textContent = rooms.length === 1 ? '1 open' : rooms.length + ' open'; + rooms.slice(0, 4).forEach(function(room) { + var card = document.createElement('article'); + card.className = 'landing-room-card'; + + var copy = document.createElement('div'); + var title = document.createElement('div'); + title.className = 'landing-room-title'; + title.textContent = String(room.name || 'Untitled room'); + var meta = document.createElement('div'); + meta.className = 'landing-room-meta'; + var active = Number(room.activeSessions || 0); + var activeText = active > 0 + ? active.toLocaleString('en-US') + (room.activeSessionsCapped ? '+' : '') + ' active' + : 'Open for guests'; + meta.textContent = activeText + ' · code ' + String(room.roomCode || '').toUpperCase(); + copy.appendChild(title); + copy.appendChild(meta); + + var join = document.createElement('a'); + join.className = 'landing-room-join'; + join.href = roomUrl(room); + join.textContent = 'Request to join'; + card.appendChild(copy); + card.appendChild(join); + list.appendChild(card); + }); } function connect() { @@ -2231,6 +2395,9 @@ client.onUpdate('events:getLandingStats', {}, function (stats) { try { render(stats); } catch (e) {} }); + client.onUpdate('events:listPublicRooms', { limit: 4 }, function (rooms) { + try { renderPublicRooms(rooms); } catch (e) {} + }); }); }) .catch(function () { /* honest: backend unreachable -> stat stays hidden */ }); @@ -2277,6 +2444,10 @@

The room remembers everything.

+

@@ -2284,6 +2455,13 @@

The room remembers everything.

Read the docs →
+
+
+ Open public rooms + +
+
+
No-account join. Public answers stay sourced. diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 2dd5ac6e..594194f1 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -118,6 +118,10 @@ async function fulfillScratchNodePage( const s = window.__snLandingStats || { roomsCreated: 0, liveNow: 0, capped: false }; setTimeout(() => cb(s), 0); } + if (name === 'events:listPublicRooms') { + const rooms = window.__snPublicRooms || []; + setTimeout(() => cb({ rooms, activeWindowMs: 1800000 }), 0); + } } } `, @@ -347,6 +351,27 @@ test.describe("ScratchNode live route honesty", () => { await expect.poll(() => page.url(), { timeout: 5_000 }).toContain("/e/launch-room"); }); + test("landing create can opt a room into public discovery", async ({ page }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/", { waitUntil: "domcontentloaded" }); + await page.fill("#landing-create-name", "Open Demo Office Hours"); + await page.check("#landing-create-public"); + await page.click("#landing-create-btn"); + + await expect + .poll( + () => page.evaluate(() => JSON.parse(localStorage.getItem("__snCreatedEventArgs") || "{}")), + { timeout: 5_000 }, + ) + .toMatchObject({ + title: "Open Demo Office Hours", + status: "live", + publicDiscoverable: true, + joinPolicy: "open", + }); + }); + test("landing create rejects a too-short name without calling the backend", async ({ page }) => { await fulfillScratchNodePage(page); @@ -380,6 +405,36 @@ test.describe("ScratchNode live route honesty", () => { await expect(page.locator("#landing-create-btn")).toBeEnabled(); }); + test("landing renders discoverable public rooms from real backend data", async ({ page }) => { + await fulfillScratchNodePage(page); + await page.addInitScript(() => { + (window as any).__snLandingStats = { roomsCreated: 12, liveNow: 2, capped: false }; + (window as any).__snPublicRooms = [ + { + eventId: "liveEvents:open", + slug: "open-office-hours", + name: "Open Office Hours", + roomCode: "OFFICE", + startedAt: 1770000000000, + lastActivityAt: 1770000001000, + activeSessions: 6, + activeSessionsCapped: false, + joinPolicy: "open", + }, + ]; + }); + + await page.goto("https://scratchnode.live/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("#landing-public")).toHaveAttribute("data-visible", "true", { + timeout: 6_000, + }); + await expect(page.locator(".landing-room-title")).toHaveText("Open Office Hours"); + await expect(page.locator(".landing-room-meta")).toContainText("6 active"); + await expect(page.locator(".landing-room-meta")).toContainText("OFFICE"); + await expect(page.locator(".landing-room-join")).toHaveText("Request to join"); + await expect(page.locator(".landing-room-join")).toHaveAttribute("href", "/e/open-office-hours"); + }); + test("landing surfaces a live 'big number' room counter from real backend data", async ({ page }) => { await fulfillScratchNodePage(page); await page.addInitScript(() => { From ca452939bb196aa89b0ca6bf9cf5b99f0b0dc9ed Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 2 Jun 2026 14:21:27 -0700 Subject: [PATCH 03/91] Harden housekeeping report cleanup --- scripts/repo/checkAugmentUploadScope.ps1 | 45 +++++++++++++++++++-- scripts/repo/mapReduceLocalHistory.ps1 | 48 +++++++++++++++++++++-- scripts/repo/runWorkspaceHousekeeping.ps1 | 4 ++ 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/scripts/repo/checkAugmentUploadScope.ps1 b/scripts/repo/checkAugmentUploadScope.ps1 index d99def79..b10f6323 100644 --- a/scripts/repo/checkAugmentUploadScope.ps1 +++ b/scripts/repo/checkAugmentUploadScope.ps1 @@ -110,6 +110,36 @@ if (Test-Path -LiteralPath $augmentIgnorePath) { $patterns = @(Get-Content -LiteralPath $augmentIgnorePath) } +$criticalIgnoreProbePaths = @( + ".git/config", + "node_modules/example.js", + ".tmp/workspace-housekeeping-loop.json", + ".tmp/local-history-map-reduce.json", + ".tmp/augment-upload-scope.json", + ".tmp/workspace-footprint.json", + ".tmp/workspace-housekeeping-verification.json", + ".tmp/workspace-housekeeping-self-test.json", + ".worktrees/example/file.txt", + ".claude/worktrees/example/file.txt", + ".claude/projects/example.json", + ".overstory/example.json", + ".serena/example.json", + "test-results/example.json", + "playwright-report/index.html", + "scripts/eval-harness/results/example.json" +) + +$criticalIgnoreProbes = @( + $criticalIgnoreProbePaths | ForEach-Object { + [pscustomobject]@{ + path = $_ + augmentIgnored = Test-AugmentIgnored $_ $patterns + } + } +) +$criticalIgnoreProbeFailures = @($criticalIgnoreProbes | Where-Object { -not $_.augmentIgnored }) +$criticalIgnoreProbesPassed = $criticalIgnoreProbeFailures.Count -eq 0 + $includedTracked = New-Object System.Collections.Generic.List[string] $excludedTracked = New-Object System.Collections.Generic.List[string] foreach ($file in $trackedFiles) { @@ -131,13 +161,15 @@ foreach ($file in $untrackedFiles) { } $candidateFiles = $includedTracked.Count + $includedUntracked.Count -$passed = $candidateFiles -le $Threshold +$candidateCountPassed = $candidateFiles -le $Threshold +$passed = $candidateCountPassed -and $criticalIgnoreProbesPassed $report = [pscustomobject]@{ generatedAt = (Get-Date).ToUniversalTime().ToString("o") repo = $repoRoot threshold = $Threshold passed = $passed + candidateCountPassed = $candidateCountPassed candidateFiles = $candidateFiles trackedFiles = $trackedFiles.Count trackedIncluded = $includedTracked.Count @@ -145,19 +177,26 @@ $report = [pscustomobject]@{ untrackedFiles = $untrackedFiles.Count untrackedIncluded = $includedUntracked.Count untrackedExcludedByAugmentignore = $excludedUntracked.Count + criticalIgnoreProbesPassed = $criticalIgnoreProbesPassed + criticalIgnoreProbeFailures = $criticalIgnoreProbeFailures.Count augmentIgnorePath = if (Test-Path -LiteralPath $augmentIgnorePath) { $augmentIgnorePath } else { $null } samples = [pscustomobject]@{ trackedExcludedByAugmentignore = @($excludedTracked | Select-Object -First $SampleLimit) untrackedIncluded = @($includedUntracked | Select-Object -First $SampleLimit) untrackedExcludedByAugmentignore = @($excludedUntracked | Select-Object -First $SampleLimit) } + criticalIgnoreProbes = $criticalIgnoreProbes } $outPath = Join-Path $repoRoot $Out New-Item -ItemType Directory -Path (Split-Path -Parent $outPath) -Force | Out-Null $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $outPath -Encoding utf8 -$report | Select-Object generatedAt, repo, threshold, passed, candidateFiles, trackedFiles, trackedIncluded, trackedExcludedByAugmentignore, untrackedFiles, untrackedIncluded, untrackedExcludedByAugmentignore, augmentIgnorePath | ConvertTo-Json -Depth 4 +$report | Select-Object generatedAt, repo, threshold, passed, candidateCountPassed, candidateFiles, trackedFiles, trackedIncluded, trackedExcludedByAugmentignore, untrackedFiles, untrackedIncluded, untrackedExcludedByAugmentignore, criticalIgnoreProbesPassed, criticalIgnoreProbeFailures, augmentIgnorePath | ConvertTo-Json -Depth 4 -if (-not $passed) { +if (-not $candidateCountPassed) { throw "Augment upload candidate count $candidateFiles exceeds threshold $Threshold. Review .augmentignore and local history before opening this workspace in Augment." } + +if (-not $criticalIgnoreProbesPassed) { + throw "Critical Augment ignore probes failed. Review .augmentignore coverage for generated reports, local history, and worktrees." +} diff --git a/scripts/repo/mapReduceLocalHistory.ps1 b/scripts/repo/mapReduceLocalHistory.ps1 index ee9dd9ae..81da1ba1 100644 --- a/scripts/repo/mapReduceLocalHistory.ps1 +++ b/scripts/repo/mapReduceLocalHistory.ps1 @@ -102,6 +102,31 @@ function Test-WorktreeDirty([string]$Path) { return $status.Count -gt 0 } +function Test-CleanupLocked([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + if (-not $item) { return $true } + + $files = if ($item.PSIsContainer) { + @(Get-ChildItem -LiteralPath $Path -File -Recurse -Force -ErrorAction SilentlyContinue) + } else { + @($item) + } + + foreach ($file in $files) { + $stream = $null + try { + $stream = [System.IO.File]::Open($file.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None) + } catch { + return $true + } finally { + if ($stream) { $stream.Close() } + } + } + + return $false +} + $buckets = [ordered]@{ safe = [System.Collections.Generic.List[object]]::new() caution = [System.Collections.Generic.List[object]]::new() @@ -118,13 +143,20 @@ $tmpKeep = [System.Collections.Generic.HashSet[string]]::new([System.StringCompa "workspace-housekeeping-loop.json", "workspace-housekeeping-verification.json", "workspace-housekeeping-self-test.json" + "scratchnode-launch-goal-loop.json", + "scratchnode-launch-scan.json" ) | ForEach-Object { $tmpKeep.Add($_) | Out-Null } $tmpPath = Join-Path $repoRoot ".tmp" if (Test-Path -LiteralPath $tmpPath) { foreach ($child in Get-ChildItem -LiteralPath $tmpPath -Force -ErrorAction SilentlyContinue) { if (-not $tmpKeep.Contains($child.Name)) { - Add-Entry $buckets (New-Entry "safe" (Join-Path ".tmp" $child.Name) "generated .tmp child") + $relativeChild = Join-Path ".tmp" $child.Name + if (Test-CleanupLocked $child.FullName) { + Add-Entry $buckets (New-Entry "keep" $relativeChild "locked generated .tmp child" @{ locked = $true }) + } else { + Add-Entry $buckets (New-Entry "safe" $relativeChild "generated .tmp child") + } } } } @@ -203,14 +235,22 @@ foreach ($wt in Get-Worktrees) { } } -$actions = [ordered]@{ safeCleanupApplied = [bool]$ApplySafe; cleanWorktreePruneApplied = [bool]$ApplyCleanWorktrees; removedSafe = @(); prunedWorktrees = @() } +$actions = [ordered]@{ safeCleanupApplied = [bool]$ApplySafe; cleanWorktreePruneApplied = [bool]$ApplyCleanWorktrees; removedSafe = @(); skippedSafe = @(); prunedWorktrees = @() } if ($ApplySafe) { foreach ($entry in @($buckets.safe)) { $target = Assert-InRepo $entry.absolutePath if (Test-Path -LiteralPath $target) { - Remove-Item -LiteralPath $target -Recurse -Force - $actions.removedSafe += $entry.path + try { + Remove-Item -LiteralPath $target -Recurse -Force + $actions.removedSafe += $entry.path + } catch { + $actions.skippedSafe += [pscustomobject]@{ + path = $entry.path + reason = "cleanup failed; preserving generated path" + error = $_.Exception.Message + } + } } } } diff --git a/scripts/repo/runWorkspaceHousekeeping.ps1 b/scripts/repo/runWorkspaceHousekeeping.ps1 index accad412..39148955 100644 --- a/scripts/repo/runWorkspaceHousekeeping.ps1 +++ b/scripts/repo/runWorkspaceHousekeeping.ps1 @@ -62,12 +62,14 @@ Invoke-JsonScript $historyScript | Out-Null $initialHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" $safeBefore = [int]$initialHistory.summary.safe.entries $removedSafe = @() +$skippedSafe = @() $prunedWorktrees = @() if ($safeBefore -gt 0) { Invoke-JsonScript $historyScript @("-ApplySafe") | Out-Null $safeCleanupHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" $removedSafe = @($safeCleanupHistory.actions.removedSafe) + $skippedSafe = @($safeCleanupHistory.actions.skippedSafe) } if ($ApplyCleanWorktrees) { @@ -118,6 +120,8 @@ $report = [ordered]@{ safeCleanupApplied = ($safeBefore -gt 0) removedSafeCount = $removedSafe.Count removedSafe = $removedSafe + skippedSafeCount = $skippedSafe.Count + skippedSafe = $skippedSafe cleanWorktreePruneApplied = [bool]$ApplyCleanWorktrees prunedWorktreeCount = $prunedWorktrees.Count prunedWorktrees = $prunedWorktrees From 3eda703cc634095df09ce6cea8ec9fcf57beefac Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 2 Jun 2026 14:32:20 -0700 Subject: [PATCH 04/91] Restore ScratchNode launch contract markers --- public/proto/home-v5.html | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index c5fad9d4..c875c5d2 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -179,6 +179,18 @@ .hero-meta { font-size: 13px; color: var(--ink-muted); } .hero-meta b { color: var(--ink); font-weight: 600; } +/* First-time flow rail: the room loop in one glance before interaction. */ +.first-flow { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px 9px; + margin: -8px 0 16px; padding: 0 0 14px; + border-bottom: 1px solid var(--line); + color: var(--ink-muted); font-size: 11px; line-height: 1.35; +} +.first-flow__item { display: inline-flex; align-items: baseline; gap: 5px; min-height: 24px; } +.first-flow__item b { color: var(--ink); font-size: 12px; font-weight: 700; } +.first-flow__item small { color: var(--ink-faint); font-size: 10px; } +.first-flow__sep { color: var(--ink-faint); font-family: var(--mono); font-size: 10px; } + /* ─── Composer (gravitational center) ─── */ .c { position: sticky; top: 64px; z-index: 40; @@ -1688,6 +1700,10 @@ body[data-page-mode="landing"] .welcome { display: none !important; } +body[data-page-mode="landing"] .menu-sheet:not([data-open="true"]), +body[data-page-mode="landing"] .menu-scrim:not([data-open="true"]) { + display: none !important; +} /* Landing layout — minimal, centered, glass-card hero. */ .landing { @@ -2518,6 +2534,18 @@

AI Infra Summit ' ); }); - return safe; + return renderEventLogTags(safe); } // Delegated click + keyboard handler for any .mention button (works for // dynamically-injected mentions, agent-card mentions, and notes-editor mentions). diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index dbede7f9..e6645cb7 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -427,6 +427,32 @@ function scanHomeV5() { }); } + const hasPeopleCompanyTagContract = + /var\s+ROOM_MEMBERS\s*=\s*\[/i.test(html) && + /function\s+renderMentions\s*\(/i.test(html) && + /function\s+renderEventLogTags\s*\(/i.test(html) && + /data-member/i.test(html) && + /data-event-log-tag/i.test(html) && + /renderEventLogTags\(safe\)/i.test(html) && + /textEl\.innerHTML\s*=\s*renderMentions\(raw\)/i.test(html); + addCheck({ + ok: hasPeopleCompanyTagContract, + name: "people and company tags project as typed public event-log context", + plane: "event-log", + detail: "@mentions + #tags render from public row decoration", + }); + if (!hasPeopleCompanyTagContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "event-log", + title: "People/company event-log tag contract is missing or incomplete", + path, + recommendation: + "Ensure public chat row decoration renders typed @mentions and #company/topic tags without deriving them from private notes.", + }); + } + const hasPrivateAskBranchContract = /function\s+parseComposerIntent\s*\(/i.test(html) && /\/ask private[\s\S]{0,140}private notebook save[\s\S]{0,120}no public agent call/i.test(html) && diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 6b089a23..303cca2f 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -414,6 +414,72 @@ test.describe("ScratchNode live route honesty", () => { expect(chatState.askCalls).toEqual([]); }); + test("typed people and company tags stay public-row context while private tagged follow-ups stay private", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const publicText = "@Alex Chen says #Orbital needs the VoiceLayer follow-up"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicText); + + const publicRow = page.locator(".row", { hasText: "VoiceLayer follow-up" }).first(); + await expect(publicRow.locator('.mention[data-member="Alex Chen"]')).toHaveText("@Alex Chen"); + await expect(publicRow.locator('.hashtag[data-event-log-tag="orbital"]')).toHaveText( + "#Orbital", + ); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + await page.locator("#lock").click(); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + + const privateText = "@Sarah Kim #MedLayer private follow-up on healthcare pilots"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator('.row .mention[data-member="Sarah Kim"]')).toHaveCount(0); + await expect(page.locator('.row .hashtag[data-event-log-tag="medlayer"]')).toHaveCount(0); + await expect(page.locator(".ans", { hasText: "healthcare pilots" })).toHaveCount(0); + + const state = await page.evaluate(({ publicText, privateText }) => { + const win = window as any; + return { + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === publicText, + ), + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === privateText, + ), + }; + }, { publicText, privateText }); + + expect(state.actions).toEqual([]); + expect(state.publicSendCalls).toHaveLength(1); + expect(state.publicSendCalls[0].args.kind).toBe("chat"); + expect(state.privateSendCalls).toEqual([]); + }); + test("locked composer saves a private note without public chat or agent calls", async ({ page }) => { await fulfillScratchNodePage(page); From 881253e647a26466cf1975477353207e8bf536c5 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 02:32:06 -0700 Subject: [PATCH 44/91] Clarify ScratchNode public repo positioning --- .../runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md | 4 ++-- .../repo/export-scratchnode-live-public.mjs | 19 +++++++++++++++---- scripts/scratchnode/scanLaunch.mjs | 16 ++++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md b/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md index 5cfc067e..25944d38 100644 --- a/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md +++ b/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md @@ -185,9 +185,9 @@ users:mergeGuestSession Use this framing: -> ScratchNode Live is a public event-room prototype where people join with a code, chat normally, use `/ask` for sourced answers, and leave behind a public event wiki. Private notes stay private and can sync into NodeBench later. +> ScratchNode Live is an open-source event log assistant and memory layer for live events. People join with a code, chat normally, use `/ask` for sourced answers, and leave behind a public event wiki. Private notes stay private and can sync into NodeBench later. -Do not claim the public split is the full NodeBench backend. Do not claim every URL contract is fully implemented just because the Vercel catch-all returns `200`. +Do not claim the public split is the full NodeBench backend. Do not claim every URL contract is fully implemented just because the Vercel catch-all returns `200`. Do not describe the public export as a final production system. ## Release Verification diff --git a/scripts/repo/export-scratchnode-live-public.mjs b/scripts/repo/export-scratchnode-live-public.mjs index ce59fd6d..e64bd99e 100644 --- a/scripts/repo/export-scratchnode-live-public.mjs +++ b/scripts/repo/export-scratchnode-live-public.mjs @@ -127,9 +127,9 @@ function writeGeneratedFiles(copiedEntries) { function buildReadme() { return `# ScratchNode Live -**Live rooms that remember.** +**Open-source event log assistant for live rooms.** -ScratchNode is a public event-room prototype where people join with a code, chat normally, use \`/ask\` for sourced answers, and leave behind a public wiki. Private notes stay private and can sync into NodeBench later. +ScratchNode is an open-source event log assistant and memory layer for live events. People join with a code, chat normally, use \`/ask\` for sourced answers, and leave behind a public event wiki. Private notes stay private and can sync into NodeBench later. - No app required. - No account required for public room join. @@ -162,7 +162,7 @@ npm run dev ## Status -This is a sanitized public frontend split. The Convex backend, NodeBench workspace, MCP services, internal evals, and deploy orchestration remain in the private \`nodebench-ai\` monorepo. +This is a sanitized public frontend split and architecture reference. The Convex backend, NodeBench workspace, MCP services, internal evals, and deploy orchestration remain in the private \`nodebench-ai\` monorepo. See [docs/prototype-vs-production.md](docs/prototype-vs-production.md). @@ -260,7 +260,7 @@ function buildPackageJson() { version: "0.1.0", private: true, type: "module", - description: "Live event rooms that turn public chat and /ask answers into a wiki while private notes stay private.", + description: "Open-source event log assistant and memory layer for live events.", scripts: { dev: "vercel dev", verify: "npm run verify:static", @@ -460,6 +460,7 @@ import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const required = [ + "README.md", "public/proto/home-v5.html", "public/proto/docs.html", "api/scratchnode-config.js", @@ -492,6 +493,16 @@ if (presentForbidden.length) { } const contract = JSON.parse(fs.readFileSync(path.join(root, "contracts/scratchnode-live-api.json"), "utf8")); +const readme = fs.readFileSync(path.join(root, "README.md"), "utf8"); +const positioningText = readme + "\\n" + JSON.stringify(contract); +if (!/open-source event log assistant/i.test(positioningText) || !/memory layer for live events/i.test(positioningText)) { + console.error("Public export positioning must say open-source event log assistant and memory layer for live events."); + process.exit(1); +} +if (/\\bproduction[-\\s]+(?:ready|grade)\\b/i.test(readme)) { + console.error("Public README must not claim final production status."); + process.exit(1); +} const eventLog = contract.eventLogProjections || {}; const publicLog = eventLog.publicEventLogJson || {}; const privateProjection = eventLog.ownerPrivateNoteProjection || {}; diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index e6645cb7..5b2a778d 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -667,6 +667,22 @@ function scanPublicRepoReadiness() { plane: "public-repo", detail: "publicEventLogJson + ownerPrivateNoteProjection", }); + addCheck({ + ok: + /open-source event log assistant/i.test(exportScript) && + /memory layer for live events/i.test(exportScript) && + /open-source event log assistant/i.test(splitRunbook) && + /memory layer for live events/i.test(splitRunbook), + name: "public export uses event-log assistant positioning", + plane: "public-repo", + detail: "open-source event log assistant + memory layer for live events", + }); + addCheck({ + ok: !/\bproduction[-\s]+(?:ready|grade)\b/i.test(`${exportScript}\n${splitRunbook}`), + name: "public export avoids final-production claims", + plane: "public-repo", + detail: "no final-production status claim in public export/runbook wording", + }); addCheck({ ok: /ScratchNode|NodeBench/i.test(readme), name: "root README names product context", From 17b6f295231b214fa6c44f96d1023450e07f560e Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 02:43:58 -0700 Subject: [PATCH 45/91] Guard ScratchNode event log route evidence --- scripts/scratchnode/scanLaunch.mjs | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 5b2a778d..ea3c6f6a 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -26,8 +26,10 @@ const files = { goalRunbook: "docs/runbooks/GOAL_MODE_RELEASE_AUTOPILOT.md", goalQueue: "goals/README.md", scratchnodeGoal: "goals/scratchnode/001-first-time-user-clarity.md", + scratchnodeEventLogGoal: "goals/scratchnode/004-event-log-followups.md", nodebenchGoal: "goals/nodebench/001-event-handoff.md", runtimeGoal: "goals/runtime/001-public-private-boundary.md", + routeHonestySpec: "tests/e2e/scratchnode-live-route-honesty.spec.ts", demoQa: "qa/run_demo_full.md", readme: "README.md", license: "LICENSE", @@ -629,8 +631,10 @@ function scanPublicRepoReadiness() { files.goalRunbook, files.goalQueue, files.scratchnodeGoal, + files.scratchnodeEventLogGoal, files.nodebenchGoal, files.runtimeGoal, + files.routeHonestySpec, files.demoQa, files.license, files.security, @@ -642,6 +646,7 @@ function scanPublicRepoReadiness() { const exportScript = readText(files.exportScript); const splitRunbook = readText(files.splitRunbook); const readme = readText(files.readme); + const routeHonestySpec = readText(files.routeHonestySpec); addCheck({ ok: /Explicit Exclusions/i.test(splitRunbook) && /convex\//i.test(splitRunbook), @@ -690,6 +695,55 @@ function scanPublicRepoReadiness() { detail: files.readme, optional: true, }); + addCheck({ + ok: + /normal public chat stays human and never invokes the agent/i.test(routeHonestySpec) && + /kind:\s*"chat"/i.test(routeHonestySpec) && + /events:sendMessage/i.test(routeHonestySpec), + name: "event-log route spec covers public timeline moments", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /typed people and company tags stay public-row context while private tagged follow-ups stay private/i.test( + routeHonestySpec, + ) && + /data-event-log-tag/i.test(routeHonestySpec) && + /privateSendCalls/i.test(routeHonestySpec), + name: "event-log route spec covers tag visibility boundaries", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /private notes anchored from public messages preserve context without public leakage/i.test(routeHonestySpec) && + /anchorType:\s*"message"/i.test(routeHonestySpec) && + /private-note-marker/i.test(routeHonestySpec), + name: "event-log route spec covers private note anchors", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /verified host publishes promoted public answers into the wiki without leaking private notes/i.test( + routeHonestySpec, + ) && + /__snMockPublishedWiki/i.test(routeHonestySpec) && + /not\.toContain\(privateNoteText\)/i.test(routeHonestySpec), + name: "event-log route spec covers public wiki projection boundary", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /NodeBench handoff has a tokenized private route and an honest shipped fallback/i.test(routeHonestySpec) && + /buildNodeBenchTokenizedPrivateUrl/i.test(routeHonestySpec) && + /publicArtifact=event-wiki/i.test(routeHonestySpec), + name: "event-log route spec covers NodeBench handoff separation", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); } function scanGoalAutomationReadiness() { From 62c6decc97d7d0010a43f0977d434d0ccc7e4cc2 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 02:55:29 -0700 Subject: [PATCH 46/91] test: prove scratchnode handoff privacy --- AGENT_COORDINATION.md | 2 + scripts/scratchnode/scanLaunch.mjs | 17 ++++ .../scratchnode-live-route-honesty.spec.ts | 97 +++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index 407a18b2..0dc188b5 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -110,6 +110,8 @@ Keep entries short and honest. Newest on top within each section. ## Recently shipped (this ScratchNode session) +- **Codex** - visibility-safe NodeBench handoff proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#nodebench-handoff` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves private follow-up text, tags, note ids, anchor ids/previews, public anchor text, and session ids stay out of fallback/tokenized handoff URLs; launch scanner now requires the proof before passing. + - **Claude** — public `/wiki/` reader (`home-v5.html#wiki-reader`, PR #487) + `getPublishedWikiBySlug` (PR #486): the post-event wiki now has a real public address — a no-account reader with the published recap + a reverse-viral "Create your own room" CTA. `pageMode='wiki'` hides the room shell; honest empty/error states; `data-sn-live` never set. Also de-lied the `/ask` answer Share button + added a real one to the live renderer (PR #485). 3 wiki e2e + 6 backend scenarios + 20 honesty suite green. - **KNOWN GAP (do not re-add blindly):** the **NodeBench bridge is BROKEN** — `nodebenchai.com/events//wiki` and `.../private` 404 (the `src/App.tsx` `/events` route regex `^/events/([^/]+)/?$` rejects trailing segments; no surface reads `source=scratchnode`/`room`/`continuation`/`publicArtifact`; no Convex importer). The pre-existing `openNodeBenchPrivateHandoff` CTAs (home-v5.html ~3452, ~4689) dead-end too. **Next:** add the real receiving route `/events/:slug/wiki` in `src/App.tsx` (render the wiki via the public `getPublishedWikiBySlug`) + then wire the "Continue in NodeBench" CTA. Ship route-first so the CTA can't 404. - **Claude** — directory viral slice (`home-v5.html#directory`): flyer cards + "● N inside" presence cue + policy-aware action (open → "Join now"; request → "Request to join" `

+

AI Infra Summit

Disposable event brain · 0 joined · public wiki later

@@ -5008,7 +5008,7 @@

Keyboard shortcuts

// ── Center: article ── h.push('
'); h.push(''); - h.push('

AI Infra Summit · Wiki

'); + h.push('

AI Infra Summit · Wiki

'); h.push('

Published v1 — regenerated each round from public panel transcripts, /ask conversations, and verified sources. 318 attendees, 47 questions answered, 38 sources cited.

'); h.push('

Overview #

'); From 2e85edbd78d1b574b60a6b88e2ff5b041ee758f7 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 03:53:45 -0700 Subject: [PATCH 51/91] perf: pause landing stats poll when hidden --- public/proto/home-v5.html | 1 + 1 file changed, 1 insertion(+) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 07ada87d..c6c3c493 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -2836,6 +2836,7 @@ } catch (e) { /* fall through to polling */ } } var poll = function () { + if (document.hidden) return; client.query('events:getLandingStats', {}) .then(function (stats) { try { render(stats); } catch (e) {} }) .catch(function () {}); From 3f599459ca7c16b034ef03148a6b8fc341ad45eb Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 04:02:35 -0700 Subject: [PATCH 52/91] Add wiki bridge launch proof --- scripts/scratchnode/scanLaunch.mjs | 54 +++++++++++++++++++ .../views/ScratchnodeWikiBridge.test.tsx | 10 ++++ 2 files changed, 64 insertions(+) diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index f4663ed1..89fac80a 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -1824,6 +1824,60 @@ async function runInteractiveChecks() { }; }); + // Stable runtime proof for the ScratchNode -> NodeBench public wiki bridge: + // an unpublished slug must still resolve to the dedicated bridge surface with + // an honest empty state and a room-specific return link, never a 404 or + // cockpit fallback. Published rendering stays covered by the component test. + await pageCheck( + "nodebench scratchnode wiki bridge empty-state contract", + "https://nodebenchai.com/events/not-published/wiki?source=scratchnode&room=ORBITAL", + async (page) => { + await page.waitForSelector("body", { timeout: 15_000 }); + await page.waitForTimeout(1200); + const data = await page.evaluate(() => { + const text = (document.body.textContent ?? "").replace(/\s+/g, " ").trim(); + const scratchnodeLinks = [...document.querySelectorAll("a")] + .map((link) => ({ + text: link.textContent?.replace(/\s+/g, " ").trim() ?? "", + href: link.href, + })) + .filter((link) => /scratchnode\.live/i.test(link.href)); + return { + title: document.title, + hasRoot: !!document.querySelector("#root"), + hasBridgeShell: !!document.querySelector('[data-testid="scratchnode-wiki-bridge"]'), + hasEmptyState: !!document.querySelector('[data-testid="scratchnode-wiki-bridge-empty"]'), + hasRecapBody: !!document.querySelector('[data-testid="scratchnode-wiki-bridge-body"]'), + hasHonestEmptyCopy: /has(?:n['\u2019]t| not) published its wiki yet/i.test(text), + hasFake404: /404|not found|page not found/i.test(text), + scratchnodeLinks, + }; + }); + const ok = + /NodeBench/i.test(data.title) && + data.hasRoot && + data.hasBridgeShell && + data.hasEmptyState && + !data.hasRecapBody && + data.hasHonestEmptyCopy && + !data.hasFake404 && + data.scratchnodeLinks.some((link) => link.href === "https://scratchnode.live/e/orbital"); + return { + ok, + detail: JSON.stringify({ + title: data.title, + hasRoot: data.hasRoot, + hasBridgeShell: data.hasBridgeShell, + hasEmptyState: data.hasEmptyState, + hasRecapBody: data.hasRecapBody, + hasHonestEmptyCopy: data.hasHonestEmptyCopy, + hasFake404: data.hasFake404, + scratchnodeLinks: data.scratchnodeLinks.slice(0, 3), + }), + }; + }, + ); + await browser.close(); } diff --git a/src/features/events/views/ScratchnodeWikiBridge.test.tsx b/src/features/events/views/ScratchnodeWikiBridge.test.tsx index 097f494e..d29279b6 100644 --- a/src/features/events/views/ScratchnodeWikiBridge.test.tsx +++ b/src/features/events/views/ScratchnodeWikiBridge.test.tsx @@ -61,6 +61,16 @@ describe("ScratchnodeWikiBridge", () => { expect(screen.queryByTestId("scratchnode-wiki-bridge-body")).toBeNull(); }); + it("uses the explicit room code for the ScratchNode return link when no wiki is published yet", () => { + useQueryMock.mockReturnValue(null); + render(); + + expect(screen.getByText("Open in ScratchNode →")).toHaveAttribute( + "href", + "https://scratchnode.live/e/orbital", + ); + }); + it("shows a loading state while the query resolves (no premature empty/error)", () => { useQueryMock.mockReturnValue(undefined); render(); From 612989be4e226e2a1363dece0c5b9801fd383151 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 04:19:37 -0700 Subject: [PATCH 53/91] perf: pause live assist refresh when hidden --- public/proto/home-v5.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index c6c3c493..9ec1d190 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -6668,7 +6668,7 @@

Keyboard shortcuts

function _startLiveCueAutoRefresh() { if (window._live_assist._refreshTimer) return; window._live_assist._refreshTimer = setInterval(function() { - if (!window._live_assist || !window._live_assist.on) return; + if (document.hidden || !window._live_assist || !window._live_assist.on) return; var since = Date.now() - 30000; var cuePromise = _resolveLiveCueSource(since); From 5ecf7a3020fb8bcf9d90cfeadca92e84ced84078 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 04:29:11 -0700 Subject: [PATCH 54/91] perf: pause join request polling when hidden --- public/proto/home-v5.html | 1 + 1 file changed, 1 insertion(+) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 9ec1d190..592f66f8 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -3003,6 +3003,7 @@ } catch (e) { /* fall through to poll */ } } timer = setInterval(function () { + if (document.hidden) return; client.query('events:getMyJoinRequest', { slug: slug, sessionId: sessionId }) .then(function (st) { try { apply(st); } catch (_) {} }) .catch(function () {}); From 129cec667aaeff94da3be1f3028a910c56ed7994 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 04:38:22 -0700 Subject: [PATCH 55/91] perf: pause presence heartbeat when hidden --- public/proto/home-v5.html | 1 + 1 file changed, 1 insertion(+) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 592f66f8..13d53df6 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -8214,6 +8214,7 @@

Keyboard shortcuts

// ─── Presence heartbeat — every 30s ───────────────────────────────── setInterval(() => { + if (document.hidden) return; client.mutation('events:heartbeat', { eventId, sessionId }).catch(() => {}); }, 30_000); From 12abf58dfb2e9c42d6e94f0c7a8b7d039c9c56db Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 04:47:38 -0700 Subject: [PATCH 56/91] test: cover manual location spot fixtures --- .../e2e/scratchnode-live-route-honesty.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 95decb32..f0c16fed 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -564,6 +564,24 @@ test.describe("ScratchNode live route honesty", () => { await expect(publicRow.locator(".row-text")).toContainText(publicText); await expect(publicRow.locator(".sn-location-spot")).toHaveText("at Booth 12"); + const publicSpotCases = [ + { spot: "Lobby", text: "Lobby meetup before the founder demos" }, + { spot: "Panel Room A", text: "Panel Room A recap notes for the public event log" }, + { spot: "Afterparty", text: "Afterparty logistics moved to the rooftop" }, + ]; + for (const { spot, text } of publicSpotCases) { + await page.evaluate((message) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = message; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, text); + + const row = page.locator(`.row[data-location-spot="${spot}"]`, { hasText: text }); + await expect(row.locator(".row-text")).toContainText(text); + await expect(row.locator(".sn-location-spot")).toHaveText(`at ${spot}`); + } + const initialNoteCount = await page.evaluate(() => { const win = window as any; win.ensureNotesStore?.(); From fd7e93e7a60d3d3274b30ddf69f12748cb2b17fe Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 04:57:13 -0700 Subject: [PATCH 57/91] test: require manual spot route evidence --- scripts/scratchnode/scanLaunch.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 89fac80a..38a50c91 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -704,6 +704,20 @@ function scanPublicRepoReadiness() { plane: "event-log-evidence", detail: files.routeHonestySpec, }); + addCheck({ + ok: + /manual location spots render as public event-log chips without private leakage/i.test(routeHonestySpec) && + /Booth 12/i.test(routeHonestySpec) && + /Lobby/i.test(routeHonestySpec) && + /Panel Room A/i.test(routeHonestySpec) && + /Investor Lounge/i.test(routeHonestySpec) && + /Afterparty/i.test(routeHonestySpec) && + /data-location-spot/i.test(routeHonestySpec) && + /navigator\.geolocation|getCurrentPosition|watchPosition/i.test(routeHonestySpec), + name: "event-log route spec covers manual location spot fixtures", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); addCheck({ ok: /typed people and company tags stay public-row context while private tagged follow-ups stay private/i.test( From afd154ad808277ef2663e3848e36ff956c8923ea Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 05:07:26 -0700 Subject: [PATCH 58/91] Prefer published wiki room codes --- .../events/views/ScratchnodeWikiBridge.test.tsx | 10 ++++++++++ src/features/events/views/ScratchnodeWikiBridge.tsx | 7 ++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/features/events/views/ScratchnodeWikiBridge.test.tsx b/src/features/events/views/ScratchnodeWikiBridge.test.tsx index d29279b6..126c7708 100644 --- a/src/features/events/views/ScratchnodeWikiBridge.test.tsx +++ b/src/features/events/views/ScratchnodeWikiBridge.test.tsx @@ -50,6 +50,16 @@ describe("ScratchnodeWikiBridge", () => { ); }); + it("prefers the published wiki room code over a stale query room when building the return link", () => { + useQueryMock.mockReturnValue(WIKI); + render(); + + expect(screen.getAllByText(/Open in ScratchNode/)[0]).toHaveAttribute( + "href", + "https://scratchnode.live/e/rooftop", + ); + }); + it("shows an honest empty state for an unpublished/unknown room (never a fake recap)", () => { useQueryMock.mockReturnValue(null); render(); diff --git a/src/features/events/views/ScratchnodeWikiBridge.tsx b/src/features/events/views/ScratchnodeWikiBridge.tsx index 78da76a4..203fdafc 100644 --- a/src/features/events/views/ScratchnodeWikiBridge.tsx +++ b/src/features/events/views/ScratchnodeWikiBridge.tsx @@ -77,9 +77,10 @@ export function ScratchnodeWikiBridge({ slug, roomCode }: Props) { ); const publicWikiUrl = `${SCRATCHNODE_ORIGIN}/wiki/${encodeURIComponent(slug)}`; - const roomUrl = `${SCRATCHNODE_ORIGIN}/e/${encodeURIComponent( - String(roomCode || wiki?.roomCode || slug).toLowerCase(), - )}`; + // Once the published wiki loads, prefer the server-returned room code over the + // inbound query param so a stale/tampered `?room=` cannot misdirect the return link. + const roomJoinTarget = String(wiki?.roomCode || roomCode || slug).toLowerCase(); + const roomUrl = `${SCRATCHNODE_ORIGIN}/e/${encodeURIComponent(roomJoinTarget)}`; return (
Date: Wed, 3 Jun 2026 05:08:11 -0700 Subject: [PATCH 59/91] test: tighten public export privacy verifier --- scripts/repo/export-scratchnode-live-public.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/repo/export-scratchnode-live-public.mjs b/scripts/repo/export-scratchnode-live-public.mjs index e64bd99e..64b852f8 100644 --- a/scripts/repo/export-scratchnode-live-public.mjs +++ b/scripts/repo/export-scratchnode-live-public.mjs @@ -512,7 +512,11 @@ if (publicLog.visibility !== "public" || missingPublicExclusions.length) { console.error("Public event-log projection contract is incomplete."); process.exit(1); } -if (privateProjection.visibility !== "owner-only" || !(privateProjection.excludes || []).includes("public wiki JSON")) { +const requiredPrivateIncludes = ["owner private notes", "private note anchors", "private follow-ups", "NodeBench handoff context"]; +const requiredPrivateExclusions = ["public wiki JSON", "public /ask cache", "public answer traces", "other attendees' notes"]; +const missingPrivateIncludes = requiredPrivateIncludes.filter((entry) => !(privateProjection.includes || []).includes(entry)); +const missingPrivateExclusions = requiredPrivateExclusions.filter((entry) => !(privateProjection.excludes || []).includes(entry)); +if (privateProjection.visibility !== "owner-only" || missingPrivateIncludes.length || missingPrivateExclusions.length) { console.error("Owner-only private note projection contract is incomplete."); process.exit(1); } From 28c47977984bfea9b36f8eb6f7bddb79d7a39fbf Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 05:18:14 -0700 Subject: [PATCH 60/91] test: require public export privacy verifier --- scripts/scratchnode/scanLaunch.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 38a50c91..972a7fcd 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -672,6 +672,20 @@ function scanPublicRepoReadiness() { plane: "public-repo", detail: "publicEventLogJson + ownerPrivateNoteProjection", }); + addCheck({ + ok: + /requiredPrivateIncludes/i.test(exportScript) && + /requiredPrivateExclusions/i.test(exportScript) && + /owner private notes/i.test(exportScript) && + /private note anchors/i.test(exportScript) && + /private follow-ups/i.test(exportScript) && + /NodeBench handoff context/i.test(exportScript) && + /public \/ask cache/i.test(exportScript) && + /other attendees' notes/i.test(exportScript), + name: "public export verifier enforces owner-only private projection", + plane: "public-repo", + detail: "requiredPrivateIncludes + requiredPrivateExclusions", + }); addCheck({ ok: /open-source event log assistant/i.test(exportScript) && From 134a0d61b2ee60432d2c353d440db97b1bc3bfbc Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 05:32:51 -0700 Subject: [PATCH 61/91] test: deepen nodebench handoff evidence --- scripts/scratchnode/scanLaunch.mjs | 10 ++ .../scratchnode-live-route-honesty.spec.ts | 117 +++++++++++++----- 2 files changed, 98 insertions(+), 29 deletions(-) diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 972a7fcd..48966d6c 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -783,6 +783,16 @@ function scanPublicRepoReadiness() { /expect\(urls\.tokenizedKeys\)\.toEqual\(\[\s*"room",\s*"source",\s*"token"\s*\]\)/i.test( routeHonestySpec, ) && + /expect\(urls\.fallbackParams\)\.toMatchObject\(\{[\s\S]*publicArtifact:\s*"event-wiki"[\s\S]*return:\s*"https:\/\/scratchnode\.live\/e\/ai-infra-summit-2026"/i.test( + routeHonestySpec, + ) && + /expect\(urls\.tokenizedParams\)\.toEqual\(\{[\s\S]*token:\s*"qa-sentinel-token-1111111111"/i.test( + routeHonestySpec, + ) && + /expect\(urls\.fallback\)\.not\.toContain\(urls\.publicCompany\)/i.test(routeHonestySpec) && + /expect\(urls\.tokenized\)\.not\.toContain\(encodeURIComponent\(urls\.publicTopic\)\)/i.test( + routeHonestySpec, + ) && /expect\(urls\.fallback\)\.not\.toContain\(urls\.sessionId\)/i.test(routeHonestySpec) && /expect\(urls\.tokenized\)\.not\.toContain\(urls\.anchorId\)/i.test(routeHonestySpec), name: "event-log route spec proves visibility-safe NodeBench handoff URLs", diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index f0c16fed..53acb607 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -1708,7 +1708,9 @@ test.describe("ScratchNode live route honesty", () => { await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); - const publicText = "Public anchor for the private follow-up handoff"; + const publicCompany = "Northstar Grid"; + const publicTopic = "edge-routing latency"; + const publicText = `Public anchor for ${publicCompany} ${publicTopic} private follow-up handoff`; await page.evaluate((text) => { const input = document.getElementById("ci") as HTMLInputElement; input.value = text; @@ -1725,8 +1727,11 @@ test.describe("ScratchNode live route honesty", () => { }, messageId); await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); - const privateText = - "@Sarah Kim #MedLayer follow up from Investor Lounge on healthcare pilots"; + const privatePerson = "Sarah Kim"; + const privateCompany = "MedLayer"; + const privateLocation = "Investor Lounge"; + const privateTopic = "healthcare pilots"; + const privateText = `@${privatePerson} #${privateCompany} follow up from ${privateLocation} on ${privateTopic}`; await page.evaluate((text) => { const input = document.getElementById("ci") as HTMLInputElement; input.value = text; @@ -1734,29 +1739,61 @@ test.describe("ScratchNode live route honesty", () => { (window as any).sendComposerMessage(); }, privateText); - const urls = await page.evaluate(({ privateText, publicText }) => { - const win = window as any; - const note = (win._notes_v5 || []).find((entry: any) => - String(entry.title + "\n" + entry.body).includes(privateText), - ); - const fallback = win.buildNodeBenchEventPrivateUrl(); - const tokenized = win.buildNodeBenchTokenizedPrivateUrl("qa-sentinel-token-1111111111"); - const signIn = win.buildNodeBenchSignInUrl(fallback); - return { - fallback, - fallbackKeys: Array.from(new URL(fallback).searchParams.keys()).sort(), - tokenized, - tokenizedKeys: Array.from(new URL(tokenized).searchParams.keys()).sort(), - signIn, - signInKeys: Array.from(new URL(signIn).searchParams.keys()).sort(), - sessionId: win._sn_live?.sessionId || localStorage.getItem("sn_session_id") || "", - noteId: note?.id || "", - anchorId: note?.anchorId || "", - anchorPreview: note?.anchorPreview || "", + const urls = await page.evaluate( + ({ privateText, publicText, - }; - }, { privateText, publicText }); + privatePerson, + privateCompany, + privateLocation, + privateTopic, + publicCompany, + publicTopic, + }) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(privateText), + ); + const fallback = win.buildNodeBenchEventPrivateUrl(); + const tokenized = win.buildNodeBenchTokenizedPrivateUrl("qa-sentinel-token-1111111111"); + const signIn = win.buildNodeBenchSignInUrl(fallback); + const fallbackUrl = new URL(fallback); + const tokenizedUrl = new URL(tokenized); + const signInUrl = new URL(signIn); + return { + fallback, + fallbackKeys: Array.from(fallbackUrl.searchParams.keys()).sort(), + fallbackParams: Object.fromEntries(fallbackUrl.searchParams.entries()), + tokenized, + tokenizedKeys: Array.from(tokenizedUrl.searchParams.keys()).sort(), + tokenizedParams: Object.fromEntries(tokenizedUrl.searchParams.entries()), + signIn, + signInKeys: Array.from(signInUrl.searchParams.keys()).sort(), + sessionId: win._sn_live?.sessionId || localStorage.getItem("sn_session_id") || "", + noteId: note?.id || "", + anchorId: note?.anchorId || "", + anchorPreview: note?.anchorPreview || "", + privateText, + publicText, + privatePerson, + privateCompany, + privateLocation, + privateTopic, + publicCompany, + publicTopic, + }; + }, + { + privateText, + publicText, + privatePerson, + privateCompany, + privateLocation, + privateTopic, + publicCompany, + publicTopic, + }, + ); expect(urls.fallbackKeys).toEqual([ "continuation", @@ -1769,6 +1806,21 @@ test.describe("ScratchNode live route honesty", () => { ]); expect(urls.tokenizedKeys).toEqual(["room", "source", "token"]); expect(urls.signInKeys).toEqual(["intent", "return"]); + expect(urls.fallbackParams).toMatchObject({ + continuation: "private-notes", + event: "ai-infra-summit-2026", + publicArtifact: "event-wiki", + return: "https://scratchnode.live/e/ai-infra-summit-2026", + room: "ORBITAL", + source: "scratchnode", + }); + expect(urls.fallbackParams.noteCount).toMatch(/^\d+$/); + expect(Number(urls.fallbackParams.noteCount)).toBeGreaterThan(0); + expect(urls.tokenizedParams).toEqual({ + room: "ORBITAL", + source: "scratchnode", + token: "qa-sentinel-token-1111111111", + }); expect(urls.fallback).toContain("continuation=private-notes"); expect(urls.fallback).toContain("publicArtifact=event-wiki"); @@ -1776,25 +1828,32 @@ test.describe("ScratchNode live route honesty", () => { expect(urls.fallback).not.toContain(urls.privateText); expect(urls.fallback).not.toContain(encodeURIComponent(urls.privateText)); - expect(urls.fallback).not.toContain("Sarah%20Kim"); - expect(urls.fallback).not.toContain("MedLayer"); - expect(urls.fallback).not.toContain("Investor%20Lounge"); - expect(urls.fallback).not.toContain("healthcare%20pilots"); + expect(urls.fallback).not.toContain(encodeURIComponent(urls.privatePerson)); + expect(urls.fallback).not.toContain(urls.privateCompany); + expect(urls.fallback).not.toContain(encodeURIComponent(urls.privateLocation)); + expect(urls.fallback).not.toContain(encodeURIComponent(urls.privateTopic)); expect(urls.fallback).not.toContain(urls.noteId); expect(urls.fallback).not.toContain(urls.anchorId); expect(urls.fallback).not.toContain(urls.anchorPreview); expect(urls.fallback).not.toContain(urls.publicText); expect(urls.fallback).not.toContain(encodeURIComponent(urls.publicText)); + expect(urls.fallback).not.toContain(urls.publicCompany); + expect(urls.fallback).not.toContain(encodeURIComponent(urls.publicTopic)); expect(urls.fallback).not.toContain(urls.sessionId); expect(urls.tokenized).not.toContain(urls.privateText); expect(urls.tokenized).not.toContain(encodeURIComponent(urls.privateText)); - expect(urls.tokenized).not.toContain("MedLayer"); + expect(urls.tokenized).not.toContain(encodeURIComponent(urls.privatePerson)); + expect(urls.tokenized).not.toContain(urls.privateCompany); + expect(urls.tokenized).not.toContain(encodeURIComponent(urls.privateLocation)); + expect(urls.tokenized).not.toContain(encodeURIComponent(urls.privateTopic)); expect(urls.tokenized).not.toContain(urls.noteId); expect(urls.tokenized).not.toContain(urls.anchorId); expect(urls.tokenized).not.toContain(urls.anchorPreview); expect(urls.tokenized).not.toContain(urls.publicText); expect(urls.tokenized).not.toContain(encodeURIComponent(urls.publicText)); + expect(urls.tokenized).not.toContain(urls.publicCompany); + expect(urls.tokenized).not.toContain(encodeURIComponent(urls.publicTopic)); expect(urls.tokenized).not.toContain(urls.sessionId); }); }); From 119c8a1afa6ba9df17523db5259a392554617ae5 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 05:49:45 -0700 Subject: [PATCH 62/91] feat: deepen live assist followups --- public/proto/home-v5.html | 41 +++++++++++- scripts/scratchnode/scanLaunch.mjs | 15 +++++ .../scratchnode-live-route-honesty.spec.ts | 66 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 13d53df6..fdba6902 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -6940,6 +6940,26 @@

Keyboard shortcuts

.replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } +function buildLiveAssistFollowUpNote(cue) { + var text = cue && cue.text ? String(cue.text) : 'Follow up on this event cue'; + var topic = window._live_assist && window._live_assist.currentTopic + ? window._live_assist.currentTopic + : null; + var context = window._live_assist && Array.isArray(window._live_assist.context) + ? window._live_assist.context.slice(0, 4) + : []; + var lines = [ + 'Follow-up: ' + text, + '', + 'Why it matters: Deepen this after the event in NodeBench with the public wiki plus your private notes.', + 'Next step: Ask for the concrete decision, metric, owner, or source behind this cue.', + 'Evidence to capture: quote, speaker/company, promised artifact, and deadline.' + ]; + if (topic && topic.text) lines.push('Event topic: ' + topic.text + (topic.meta ? ' - ' + topic.meta : '')); + if (context.length) lines.push('Context: ' + context.join(', ')); + lines.push('Visibility: private follow-up note; not public chat or public /ask.'); + return lines.join('\n'); +} function _laCueAction(action, cueId) { var cue = (window._live_assist.cues || []).find(function(c) { return c.id === cueId; }); if (!cue) return; @@ -6953,7 +6973,26 @@

Keyboard shortcuts

input.focus(); } } else if (action === 'followup') { - if (typeof toast === 'function') { try { toast('Follow-up queued', cue.text); } catch (e) {} } + var followUpText = buildLiveAssistFollowUpNote(cue); + var room = typeof getRoomContext === 'function' ? getRoomContext() : null; + var intent = { + clean: followUpText, + isAsk: false, + kind: 'note', + visibility: 'private', + eventMode: document.body.getAttribute('data-event-mode') || 'event', + timestamp: Date.now(), + anchor: typeof buildPrivateNoteAnchor === 'function' ? buildPrivateNoteAnchor(room) : null + }; + if (typeof window.savePrivateNote === 'function') { + window.savePrivateNote(intent); + } else if (typeof savePrivateNote === 'function') { + savePrivateNote(intent); + } + if (typeof laRecordNote === 'function') { + try { laRecordNote(followUpText, 'follow-up'); } catch (e) {} + } + if (typeof toast === 'function') { try { toast('Follow-up queued privately', cue.text); } catch (e) {} } } } diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 48966d6c..6f96f173 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -646,6 +646,7 @@ function scanPublicRepoReadiness() { const exportScript = readText(files.exportScript); const splitRunbook = readText(files.splitRunbook); const readme = readText(files.readme); + const homeHtml = readText(files.homeV5); const routeHonestySpec = readText(files.routeHonestySpec); addCheck({ @@ -743,6 +744,20 @@ function scanPublicRepoReadiness() { plane: "event-log-evidence", detail: files.routeHonestySpec, }); + addCheck({ + ok: + /function buildLiveAssistFollowUpNote\s*\(/i.test(homeHtml) && + /Visibility: private follow-up note; not public chat or public \/ask\./i.test(homeHtml) && + /Live Assist follow-up cues become structured private notes without public writes/i.test( + routeHonestySpec, + ) && + /_laCueAction\?\.\("followup",\s*id\)/i.test(routeHonestySpec) && + /Follow-up: \$\{cueText\}/i.test(routeHonestySpec) && + /publicSendCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), + name: "event-log route spec covers structured private follow-up cues", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); addCheck({ ok: /private notes anchored from public messages preserve context without public leakage/i.test(routeHonestySpec) && diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 53acb607..d57fe44c 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -768,6 +768,72 @@ test.describe("ScratchNode live route honesty", () => { expect(sensitiveState.noteTexts.join("\n")).toContain(sensitivePrompt); }); + test("Live Assist follow-up cues become structured private notes without public writes", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const cueText = "Clarify scoped tool grant vs tenant RBAC"; + const cueId = await page.evaluate((text) => { + const win = window as any; + win.toggleLiveAssist?.(true); + win.setLiveAssistTopic?.("MCP auth", "Panel Room A"); + win.setLiveAssistContext?.(["@Orbital Labs", "@Alex Chen", "[[tenant RBAC]]"]); + return win.pushLiveAssistCue?.(text, { source: "route-test", skill: "follow-up-depth" }); + }, cueText); + + await expect(page.locator("#live-assist-rail")).toContainText(cueText); + await page.evaluate((id) => { + (window as any)._laCueAction?.("followup", id); + }, cueId); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: cueText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: cueText })).toHaveCount(0); + + const state = await page.evaluate((text) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(text), + ); + const noteText = String((note?.title || "") + "\n" + (note?.body || "")) + .replace(//gi, "\n") + .replace(/&/g, "&"); + return { + noteText, + recentNotes: (win._live_assist?.recentNotes || []).map((entry: any) => entry.text), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, cueText); + + expect(state.noteText).toContain(`Follow-up: ${cueText}`); + expect(state.noteText).toContain("Why it matters: Deepen this after the event in NodeBench"); + expect(state.noteText).toContain("Next step: Ask for the concrete decision"); + expect(state.noteText).toContain("Evidence to capture: quote, speaker/company"); + expect(state.noteText).toContain("Event topic: MCP auth - Panel Room A"); + expect(state.noteText).toContain("Context: @Orbital Labs, @Alex Chen, [[tenant RBAC]]"); + expect(state.noteText).toContain("Visibility: private follow-up note; not public chat or public /ask."); + expect(state.recentNotes.join("\n")).toContain(`Follow-up: ${cueText}`); + expect(state.actions).toEqual([]); + expect(state.publicSendCalls).toEqual([]); + }); + test("private /ask stays out of the public feed and increases the NodeBench handoff note count", async ({ page, }) => { From c1993417ceb8fdf8e2bebb05e193f13b72305603 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 06:00:22 -0700 Subject: [PATCH 63/91] test: require explicit followup cue action --- scripts/scratchnode/scanLaunch.mjs | 6 ++++- .../scratchnode-live-route-honesty.spec.ts | 23 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 6f96f173..e7a6b591 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -748,7 +748,11 @@ function scanPublicRepoReadiness() { ok: /function buildLiveAssistFollowUpNote\s*\(/i.test(homeHtml) && /Visibility: private follow-up note; not public chat or public \/ask\./i.test(homeHtml) && - /Live Assist follow-up cues become structured private notes without public writes/i.test( + /Live Assist follow-up cues require explicit action before private note creation/i.test( + routeHonestySpec, + ) && + /expect\(beforeAction\.noteCount\)\.toBe\(initialNoteCount\)/i.test(routeHonestySpec) && + /expect\(beforeAction\.noteTexts\.join\("\\n"\)\)\.not\.toContain\(cueText\)/i.test( routeHonestySpec, ) && /_laCueAction\?\.\("followup",\s*id\)/i.test(routeHonestySpec) && diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index d57fe44c..d1b74ef3 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -768,7 +768,7 @@ test.describe("ScratchNode live route honesty", () => { expect(sensitiveState.noteTexts.join("\n")).toContain(sensitivePrompt); }); - test("Live Assist follow-up cues become structured private notes without public writes", async ({ + test("Live Assist follow-up cues require explicit action before private note creation", async ({ page, }) => { await fulfillScratchNodePage(page); @@ -792,6 +792,27 @@ test.describe("ScratchNode live route honesty", () => { }, cueText); await expect(page.locator("#live-assist-rail")).toContainText(cueText); + const beforeAction = await page.evaluate((text) => { + const win = window as any; + return { + noteCount: win.getPrivateNoteHandoffCount?.(), + noteTexts: (win._notes_v5 || []).map((note: any) => note.title + "\n" + note.body), + recentNotes: (win._live_assist?.recentNotes || []).map((entry: any) => entry.text), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, cueText); + + expect(beforeAction.noteCount).toBe(initialNoteCount); + expect(beforeAction.noteTexts.join("\n")).not.toContain(cueText); + expect(beforeAction.recentNotes.join("\n")).not.toContain(cueText); + expect(beforeAction.actions).toEqual([]); + expect(beforeAction.publicSendCalls).toEqual([]); + await page.evaluate((id) => { (window as any)._laCueAction?.("followup", id); }, cueId); From ad53822942bb8b9bb55d761998931c9903a46f52 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 06:09:36 -0700 Subject: [PATCH 64/91] test export event-log boundaries --- .../__tests__/scratchnodePublicExport.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 scripts/repo/__tests__/scratchnodePublicExport.test.ts diff --git a/scripts/repo/__tests__/scratchnodePublicExport.test.ts b/scripts/repo/__tests__/scratchnodePublicExport.test.ts new file mode 100644 index 00000000..0744e06d --- /dev/null +++ b/scripts/repo/__tests__/scratchnodePublicExport.test.ts @@ -0,0 +1,96 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, test } from "vitest"; + +const repoRoot = path.resolve(__dirname, "..", "..", ".."); +const exportScript = path.join(repoRoot, "scripts", "repo", "export-scratchnode-live-public.mjs"); + +function runNodeScript(scriptPath: string, args: string[], cwd: string) { + const result = spawnSync(process.execPath, [scriptPath, ...args], { + cwd, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error( + [ + `Command failed: node ${path.relative(repoRoot, scriptPath)} ${args.join(" ")}`.trim(), + result.stdout, + result.stderr, + ] + .filter(Boolean) + .join("\n"), + ); + } + return result; +} + +describe("scratchnode public export", () => { + test("generates an export whose public and owner-only event-log projections stay separated", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "scratchnode-public-export-")); + const outDir = path.join(tmpRoot, "export"); + try { + runNodeScript(exportScript, ["--out", outDir], repoRoot); + + const readme = fs.readFileSync(path.join(outDir, "README.md"), "utf8"); + const invariants = fs.readFileSync(path.join(outDir, "docs", "invariants.md"), "utf8"); + const contract = JSON.parse( + fs.readFileSync(path.join(outDir, "contracts", "scratchnode-live-api.json"), "utf8"), + ) as { + surface: string; + framing: string; + eventLogProjections: { + publicEventLogJson: { visibility: string; includes: string[]; excludes: string[] }; + ownerPrivateNoteProjection: { visibility: string; includes: string[]; excludes: string[] }; + }; + }; + + expect(readme).toMatch(/open-source event log assistant/i); + expect(readme).toMatch(/memory layer for live events/i); + expect(readme).not.toMatch(/\bproduction[-\s]+(?:ready|grade)\b/i); + expect(invariants).toContain("Public event-log JSON contains public room moments only."); + expect(invariants).toContain("Owner-only private note projections are separate from public event-log JSON."); + + expect(contract.surface).toBe("open-source event log assistant"); + expect(contract.framing).toBe("memory layer for live events"); + expect(contract.eventLogProjections.publicEventLogJson).toEqual({ + visibility: "public", + includes: expect.arrayContaining([ + "event metadata", + "public chat messages", + "public /ask questions and answers", + "host-promoted FAQ/wiki sections", + "public source references", + "typed manual location spots", + ]), + excludes: expect.arrayContaining([ + "private notes", + "owner keys", + "session ids", + "handoff tokens", + "NodeBench workspace artifacts", + ]), + }); + expect(contract.eventLogProjections.ownerPrivateNoteProjection).toEqual({ + visibility: "owner-only", + includes: expect.arrayContaining([ + "owner private notes", + "private note anchors", + "private follow-ups", + "NodeBench handoff context", + ]), + excludes: expect.arrayContaining([ + "public wiki JSON", + "public /ask cache", + "public answer traces", + "other attendees' notes", + ]), + }); + + runNodeScript(path.join(outDir, "scripts", "verify-public-export.mjs"), [], outDir); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); From ee750afb86a28908ec3d2b65dd043d6245d745b8 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 06:14:03 -0700 Subject: [PATCH 65/91] fix: persist live assist cue saves --- public/proto/home-v5.html | 42 +++++++------ scripts/scratchnode/scanLaunch.mjs | 7 +++ .../scratchnode-live-route-honesty.spec.ts | 59 +++++++++++++++++++ 3 files changed, 89 insertions(+), 19 deletions(-) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index fdba6902..b1e1403c 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -6960,12 +6960,33 @@

Keyboard shortcuts

lines.push('Visibility: private follow-up note; not public chat or public /ask.'); return lines.join('\n'); } +function saveLiveAssistPrivateNote(text, source) { + var room = typeof getRoomContext === 'function' ? getRoomContext() : null; + var intent = { + clean: text, + isAsk: false, + kind: 'note', + visibility: 'private', + eventMode: document.body.getAttribute('data-event-mode') || 'event', + timestamp: Date.now(), + anchor: typeof buildPrivateNoteAnchor === 'function' ? buildPrivateNoteAnchor(room) : null + }; + if (typeof window.savePrivateNote === 'function') { + window.savePrivateNote(intent); + } else if (typeof savePrivateNote === 'function') { + savePrivateNote(intent); + } + if (typeof laRecordNote === 'function') { + try { laRecordNote(text, source || 'cue'); } catch (e) {} + } + return intent; +} function _laCueAction(action, cueId) { var cue = (window._live_assist.cues || []).find(function(c) { return c.id === cueId; }); if (!cue) return; if (action === 'save') { + saveLiveAssistPrivateNote('Cue: ' + cue.text, 'cue'); if (typeof toast === 'function') { try { toast('🔒 Cue saved as private note', cue.text); } catch (e) {} } - laRecordNote(cue.text, 'cue'); } else if (action === 'ask-private') { var input = document.getElementById('ci'); if (input) { @@ -6974,24 +6995,7 @@

Keyboard shortcuts

} } else if (action === 'followup') { var followUpText = buildLiveAssistFollowUpNote(cue); - var room = typeof getRoomContext === 'function' ? getRoomContext() : null; - var intent = { - clean: followUpText, - isAsk: false, - kind: 'note', - visibility: 'private', - eventMode: document.body.getAttribute('data-event-mode') || 'event', - timestamp: Date.now(), - anchor: typeof buildPrivateNoteAnchor === 'function' ? buildPrivateNoteAnchor(room) : null - }; - if (typeof window.savePrivateNote === 'function') { - window.savePrivateNote(intent); - } else if (typeof savePrivateNote === 'function') { - savePrivateNote(intent); - } - if (typeof laRecordNote === 'function') { - try { laRecordNote(followUpText, 'follow-up'); } catch (e) {} - } + saveLiveAssistPrivateNote(followUpText, 'follow-up'); if (typeof toast === 'function') { try { toast('Follow-up queued privately', cue.text); } catch (e) {} } } } diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index e7a6b591..e2f75bdc 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -747,7 +747,14 @@ function scanPublicRepoReadiness() { addCheck({ ok: /function buildLiveAssistFollowUpNote\s*\(/i.test(homeHtml) && + /function saveLiveAssistPrivateNote\s*\(/i.test(homeHtml) && + /saveLiveAssistPrivateNote\('Cue: '\s*\+\s*cue\.text,\s*'cue'\)/i.test(homeHtml) && /Visibility: private follow-up note; not public chat or public \/ask\./i.test(homeHtml) && + /Live Assist save cue writes an actual private note without public writes/i.test( + routeHonestySpec, + ) && + /_laCueAction\?\.\("save",\s*id\)/i.test(routeHonestySpec) && + /Cue: \$\{cueText\}/i.test(routeHonestySpec) && /Live Assist follow-up cues require explicit action before private note creation/i.test( routeHonestySpec, ) && diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index d1b74ef3..d2f4c181 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -768,6 +768,65 @@ test.describe("ScratchNode live route honesty", () => { expect(sensitiveState.noteTexts.join("\n")).toContain(sensitivePrompt); }); + test("Live Assist save cue writes an actual private note without public writes", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const cueText = "Ask Alex for the latency source after the MCP panel"; + const cueId = await page.evaluate((text) => { + const win = window as any; + win.toggleLiveAssist?.(true); + return win.pushLiveAssistCue?.(text, { source: "route-test", skill: "cue-save" }); + }, cueText); + + await expect(page.locator("#live-assist-rail")).toContainText(cueText); + await page.evaluate((id) => { + (window as any)._laCueAction?.("save", id); + }, cueId); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: cueText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: cueText })).toHaveCount(0); + + const state = await page.evaluate((text) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(text), + ); + const noteText = String((note?.title || "") + "\n" + (note?.body || "")) + .replace(//gi, "\n"); + return { + noteText, + noteCount: win.getPrivateNoteHandoffCount?.(), + recentNotes: (win._live_assist?.recentNotes || []).map((entry: any) => entry.text), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, cueText); + + expect(state.noteCount).toBe(initialNoteCount + 1); + expect(state.noteText).toContain(`Cue: ${cueText}`); + expect(state.recentNotes.join("\n")).toContain(`Cue: ${cueText}`); + expect(state.actions).toEqual([]); + expect(state.publicSendCalls).toEqual([]); + }); + test("Live Assist follow-up cues require explicit action before private note creation", async ({ page, }) => { From 75346b5ac15e6634eac27eae79a2fbb5d2d84779 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 06:27:08 -0700 Subject: [PATCH 66/91] fix: sync live assist private ask drafts --- public/proto/home-v5.html | 2 + scripts/scratchnode/scanLaunch.mjs | 5 ++ .../scratchnode-live-route-honesty.spec.ts | 77 +++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index b1e1403c..0233dc86 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -6991,6 +6991,8 @@

Keyboard shortcuts

var input = document.getElementById('ci'); if (input) { input.value = '/ask private ' + cue.text; + input.dispatchEvent(new Event('input', { bubbles: true })); + try { input.setSelectionRange(input.value.length, input.value.length); } catch (e) {} input.focus(); } } else if (action === 'followup') { diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index e2f75bdc..11c44675 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -755,6 +755,11 @@ function scanPublicRepoReadiness() { ) && /_laCueAction\?\.\("save",\s*id\)/i.test(routeHonestySpec) && /Cue: \$\{cueText\}/i.test(routeHonestySpec) && + /Live Assist ask privately cue drafts first, then sends only to private notes/i.test( + routeHonestySpec, + ) && + /expect\(draftState\.draft\)\.toBe\(`\/ask private \$\{cueText\}`\)/i.test(routeHonestySpec) && + /expect\(draftState\.inputEvents\)\.toBeGreaterThan\(0\)/i.test(routeHonestySpec) && /Live Assist follow-up cues require explicit action before private note creation/i.test( routeHonestySpec, ) && diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index d2f4c181..587bfc9b 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -827,6 +827,83 @@ test.describe("ScratchNode live route honesty", () => { expect(state.publicSendCalls).toEqual([]); }); + test("Live Assist ask privately cue drafts first, then sends only to private notes", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const cueText = "Which source proves tail latency?"; + const draftState = await page.evaluate((text) => { + const win = window as any; + const input = document.getElementById("ci") as HTMLInputElement; + win.__snInputEvents = 0; + input.addEventListener("input", () => { + win.__snInputEvents += 1; + }); + win.toggleLiveAssist?.(true); + const cueId = win.pushLiveAssistCue?.(text, { source: "route-test", skill: "cue-ask-private" }); + win._laCueAction?.("ask-private", cueId); + return { + draft: input.value, + inputEvents: win.__snInputEvents, + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd, + noteCount: win.getPrivateNoteHandoffCount?.(), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, cueText); + + expect(draftState.draft).toBe(`/ask private ${cueText}`); + expect(draftState.inputEvents).toBeGreaterThan(0); + expect(draftState.selectionStart).toBe(draftState.draft.length); + expect(draftState.selectionEnd).toBe(draftState.draft.length); + expect(draftState.noteCount).toBe(initialNoteCount); + expect(draftState.actions).toEqual([]); + expect(draftState.publicSendCalls).toEqual([]); + + await page.evaluate(() => { + (window as any).sendComposerMessage?.(); + }); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: cueText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: cueText })).toHaveCount(0); + + const sentState = await page.evaluate((text) => { + const win = window as any; + const noteTexts = (win._notes_v5 || []).map((note: any) => note.title + "\n" + note.body); + return { + noteTexts, + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, cueText); + + expect(sentState.noteTexts.join("\n")).toContain(cueText); + expect(sentState.actions).toEqual([]); + expect(sentState.publicSendCalls).toEqual([]); + }); + test("Live Assist follow-up cues require explicit action before private note creation", async ({ page, }) => { From 6e9b232fc70adad81b45db367e2ed1d12d29876a Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 06:41:52 -0700 Subject: [PATCH 67/91] test: keep host source workflow no-llm --- scripts/scratchnode/scanLaunch.mjs | 13 ++++++++++ .../scratchnode-live-route-honesty.spec.ts | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 11c44675..136b221e 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -794,6 +794,19 @@ function scanPublicRepoReadiness() { plane: "event-log-evidence", detail: files.routeHonestySpec, }); + addCheck({ + ok: + /verified host can manage room metadata, public sources, and end session/i.test(routeHonestySpec) && + /events:updateEvent/i.test(routeHonestySpec) && + /events:upsertEventSource/i.test(routeHonestySpec) && + /events:deleteEventSource/i.test(routeHonestySpec) && + /events:endEvent/i.test(routeHonestySpec) && + /hostWorkflowState\.actions/i.test(routeHonestySpec) && + /expect\(hostWorkflowState\.actions\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), + name: "event-log route spec keeps host source management no-LLM by default", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); addCheck({ ok: /NodeBench handoff has a tokenized private route and an honest shipped fallback/i.test(routeHonestySpec) && diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 587bfc9b..866638df 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -1586,6 +1586,32 @@ test.describe("ScratchNode live route honesty", () => { .toMatchObject({ eventId: "liveEvents:1" }); await expect(page.locator("#sn-manage-event-output")).toContainText("Session ended"); await expect(page.locator("#ev-mode-label")).toContainText("ended"); + + const hostWorkflowState = await page.evaluate(() => { + const win = window as any; + return { + actions: win.__snMockActions || [], + hostMutations: (win.__snMockMutations || []) + .filter((call: any) => + [ + "events:updateEvent", + "events:upsertEventSource", + "events:deleteEventSource", + "events:endEvent", + ].includes(call.name), + ) + .map((call: any) => call.name), + }; + }); + expect(hostWorkflowState.actions).toEqual([]); + expect(hostWorkflowState.hostMutations).toEqual( + expect.arrayContaining([ + "events:updateEvent", + "events:upsertEventSource", + "events:deleteEventSource", + "events:endEvent", + ]), + ); }); test("landing 'Create a room' creates a live room and enters it as host", async ({ page }) => { From 4c3f5636e77d08b0c3c1a6c74e6d217493872213 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 06:57:28 -0700 Subject: [PATCH 68/91] test: guard live assist voice privacy --- public/proto/home-v5.html | 1 + scripts/scratchnode/scanLaunch.mjs | 16 ++++ .../scratchnode-live-route-honesty.spec.ts | 87 +++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 0233dc86..68a25989 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -7277,6 +7277,7 @@

Keyboard shortcuts

window.laCompleteShorthand = laCompleteShorthand; window.laClearShorthand = laClearShorthand; window.laRecordNote = laRecordNote; +window.saveLiveAssistPrivateNote = saveLiveAssistPrivateNote; window.setEventMode = setEventMode; window.setCaptureLevel = setCaptureLevel; window.openModePicker = openModePicker; diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 136b221e..92933b62 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -748,6 +748,7 @@ function scanPublicRepoReadiness() { ok: /function buildLiveAssistFollowUpNote\s*\(/i.test(homeHtml) && /function saveLiveAssistPrivateNote\s*\(/i.test(homeHtml) && + /window\.saveLiveAssistPrivateNote\s*=\s*saveLiveAssistPrivateNote/i.test(homeHtml) && /saveLiveAssistPrivateNote\('Cue: '\s*\+\s*cue\.text,\s*'cue'\)/i.test(homeHtml) && /Visibility: private follow-up note; not public chat or public \/ask\./i.test(homeHtml) && /Live Assist save cue writes an actual private note without public writes/i.test( @@ -774,6 +775,21 @@ function scanPublicRepoReadiness() { plane: "event-log-evidence", detail: files.routeHonestySpec, }); + addCheck({ + ok: + /Live Assist voice transcript saves as a private note without public writes/i.test(routeHonestySpec) && + /laStartVoice/i.test(routeHonestySpec) && + /laUpdateVoice/i.test(routeHonestySpec) && + /saveLiveAssistPrivateNote/i.test(routeHonestySpec) && + /voiceInLiveAssist/i.test(routeHonestySpec) && + /voiceInFeed/i.test(routeHonestySpec) && + /recentVoiceNotes/i.test(routeHonestySpec) && + /expect\(savedState\.actions\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(savedState\.publicSendCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), + name: "event-log route spec covers voice transcript private-note boundary", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); addCheck({ ok: /private notes anchored from public messages preserve context without public leakage/i.test(routeHonestySpec) && diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 866638df..a07efabe 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -991,6 +991,93 @@ test.describe("ScratchNode live route honesty", () => { expect(state.publicSendCalls).toEqual([]); }); + test("Live Assist voice transcript saves as a private note without public writes", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const transcript = "Voice note: ask Alex for the source behind sub-350ms clinical latency"; + const captureState = await page.evaluate((text) => { + const win = window as any; + win.toggleLiveAssist?.(true); + win.laStartVoice?.(); + win.laUpdateVoice?.("transcribing", ""); + win.laUpdateVoice?.("transcribed", text); + return { + voiceInLiveAssist: !!document.querySelector( + "#live-assist-rail .la-card.voice, #live-assist-sheet .la-card.voice", + ), + voiceInFeed: !!document.querySelector("#feed .voice-capture"), + noteCount: win.getPrivateNoteHandoffCount?.(), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, transcript); + + expect(captureState.voiceInLiveAssist).toBe(true); + expect(captureState.voiceInFeed).toBe(false); + expect(captureState.noteCount).toBe(initialNoteCount); + expect(captureState.actions).toEqual([]); + expect(captureState.publicSendCalls).toEqual([]); + await expect(page.locator("#live-assist-rail")).toContainText(transcript); + await expect(page.locator("#feed .voice-capture")).toHaveCount(0); + + await page.evaluate((text) => { + const win = window as any; + win.saveLiveAssistPrivateNote?.(text, "voice"); + win.laUpdateVoice?.("saved", text); + }, transcript); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: transcript })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: transcript })).toHaveCount(0); + + const savedState = await page.evaluate((text) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(text), + ); + const noteText = String((note?.title || "") + "\n" + (note?.body || "")) + .replace(//gi, "\n"); + return { + noteText, + noteCount: win.getPrivateNoteHandoffCount?.(), + voiceState: win._live_assist?.voice?.state, + recentVoiceNotes: (win._live_assist?.recentNotes || []) + .filter((entry: any) => entry.source === "voice") + .map((entry: any) => entry.text), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, transcript); + + expect(savedState.noteCount).toBe(initialNoteCount + 1); + expect(savedState.noteText).toContain(transcript); + expect(savedState.voiceState).toBe("saved"); + expect(savedState.recentVoiceNotes.join("\n")).toContain(transcript); + expect(savedState.actions).toEqual([]); + expect(savedState.publicSendCalls).toEqual([]); + }); + test("private /ask stays out of the public feed and increases the NodeBench handoff note count", async ({ page, }) => { From 9da9c5ab2ae83da288bf8e485a459b50847c1700 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 07:12:03 -0700 Subject: [PATCH 69/91] fix: preserve public reply context --- public/proto/home-v5.html | 20 +++++ scripts/scratchnode/scanLaunch.mjs | 14 +++ .../scratchnode-live-route-honesty.spec.ts | 87 +++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 68a25989..088c4d6a 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -7521,6 +7521,23 @@

Keyboard shortcuts

row.querySelector('.row-time').textContent = fmtTime(msg.createdAt); row.querySelector('.row-name').textContent = msg.displayName || 'Anonymous'; row.querySelector('.row-text').textContent = (msg.kind === 'ask' ? '/ask ' : '') + msg.text; + if (msg.replyToMessageId) { + row.setAttribute('data-reply-to-message-id', msg.replyToMessageId); + const parent = document.querySelector('[data-mid="' + msg.replyToMessageId + '"]'); + if (parent) { + const parentName = parent.querySelector('.row-name') ? parent.querySelector('.row-name').textContent : ''; + const parentText = parent.querySelector('.row-text') ? parent.querySelector('.row-text').textContent : ''; + const quote = parentText.length > 50 ? parentText.slice(0, 50) + '...' : parentText; + const rb = document.createElement('div'); + rb.className = 'row-replying'; + rb.innerHTML = 'replying to '; + rb.querySelector('.name').textContent = parentName || 'someone'; + rb.querySelector('.quote').textContent = '"' + quote + '"'; + const body = row.querySelector('.row-body'); + const nameEl = body && body.querySelector('.row-name'); + if (body && nameEl) body.insertBefore(rb, nameEl); + } + } renderManualLocationSpot(row, msg.text); feedEl.appendChild(row); row.scrollIntoView({ behavior: 'smooth', block: 'end' }); @@ -7656,12 +7673,14 @@

Keyboard shortcuts

// Public chat or /ask — send to Convex. The agent answer for /ask is Phase 2; // for now the message just shows up in the feed. const liveName = (window._userName && String(window._userName).trim()) || 'Anonymous Guest'; + const room = getRoomContext(); const messagePayload = { eventId, sessionId, displayName: liveName, text: intent.clean, kind: intent.kind === 'agent_ask' ? 'ask' : 'chat', + replyToMessageId: room.replyingToMid || undefined, }; const postMessage = () => client.mutation('events:sendMessage', messagePayload); const runAskIfNeeded = (res) => { @@ -7718,6 +7737,7 @@

Keyboard shortcuts

// Clear composer; Convex subscription will render the new row when it lands. input.value = ''; + if (room.replyingToMid && typeof cancelReply === 'function') cancelReply(); if ('ontouchstart' in window || window.innerWidth <= 720) input.blur(); }; // Make sure both global aliases stay in sync (existing onclick handlers). diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 92933b62..8272f249 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -719,6 +719,20 @@ function scanPublicRepoReadiness() { plane: "event-log-evidence", detail: files.routeHonestySpec, }); + addCheck({ + ok: + /normal public replies stay chat-only event-log moments/i.test(routeHonestySpec) && + /replyToMessageId:\s*room\.replyingToMid\s*\|\|\s*undefined/i.test(homeHtml) && + /data-reply-to-message-id/i.test(homeHtml) && + /row-replying/i.test(homeHtml) && + /replySendCalls/i.test(routeHonestySpec) && + /expect\(replyState\.actions\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(replyState\.privateNoteCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(replyState\.askCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), + name: "event-log route spec covers public reply no-LLM boundary", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); addCheck({ ok: /manual location spots render as public event-log chips without private leakage/i.test(routeHonestySpec) && diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index a07efabe..734b106c 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -68,9 +68,11 @@ async function fulfillScratchNodePage( const nextMessage = { _id: messageId, eventId: args.eventId, + sessionId: args.sessionId, displayName: args.displayName, text: args.text, kind: args.kind, + replyToMessageId: args.replyToMessageId, createdAt: 1770000000000 + window.__snMockMessages.length, }; window.__snMockMessages.push(nextMessage); @@ -414,6 +416,91 @@ test.describe("ScratchNode live route honesty", () => { expect(chatState.askCalls).toEqual([]); }); + test("normal public replies stay chat-only event-log moments", async ({ page }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const parentText = "Public parent message for reply context"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, parentText); + + const parentRow = page.locator(".row", { hasText: parentText }).first(); + await expect(parentRow.locator(".row-text")).toContainText(parentText); + const parentMessageId = await parentRow.getAttribute("data-mid"); + expect(parentMessageId).toMatch(/^liveEventMessages:/); + if (!parentMessageId) throw new Error("Expected parent message id"); + + const replyText = "Replying publicly with the source owner"; + await page.evaluate( + ({ parentMessageId, replyText }) => { + const win = window as any; + const input = document.getElementById("ci") as HTMLInputElement; + win.replyTo?.(parentMessageId); + input.value = replyText; + input.dispatchEvent(new Event("input", { bubbles: true })); + win.sendComposerMessage?.(); + }, + { parentMessageId, replyText }, + ); + + const replyRow = page.locator(`.row[data-reply-to-message-id="${parentMessageId}"]`, { + hasText: replyText, + }); + await expect(replyRow.locator(".row-text")).toContainText(replyText); + await expect(replyRow.locator(".row-replying")).toContainText(parentText); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "false"); + await expect(page.locator(".ans", { hasText: replyText })).toHaveCount(0); + + const replyState = await page.evaluate( + ({ parentMessageId, replyText }) => { + const win = window as any; + return { + noteCount: win.getPrivateNoteHandoffCount?.(), + actions: win.__snMockActions || [], + replySendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === replyText && + call.args?.kind === "chat", + ), + privateNoteCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "notes:createNote", + ), + askCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.kind === "ask", + ), + }; + }, + { parentMessageId, replyText }, + ); + + expect(replyState.noteCount).toBe(initialNoteCount); + expect(replyState.actions).toEqual([]); + expect(replyState.replySendCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + text: replyText, + kind: "chat", + replyToMessageId: parentMessageId, + }), + }), + ]); + expect(replyState.privateNoteCalls).toEqual([]); + expect(replyState.askCalls).toEqual([]); + }); + test("typed people and company tags stay public-row context while private tagged follow-ups stay private", async ({ page, }) => { From 2869ad007273f642a2a32ea29dac1bdcdfebf089 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 07:21:46 -0700 Subject: [PATCH 70/91] Add wiki bridge visibility checks --- scripts/scratchnode/scanLaunch.mjs | 19 ++++++++ .../views/ScratchnodeWikiBridge.test.tsx | 47 ++++++++++++++----- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 8272f249..a56bb284 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -30,6 +30,7 @@ const files = { nodebenchGoal: "goals/nodebench/001-event-handoff.md", runtimeGoal: "goals/runtime/001-public-private-boundary.md", routeHonestySpec: "tests/e2e/scratchnode-live-route-honesty.spec.ts", + scratchnodeWikiBridgeSpec: "src/features/events/views/ScratchnodeWikiBridge.test.tsx", demoQa: "qa/run_demo_full.md", readme: "README.md", license: "LICENSE", @@ -635,6 +636,7 @@ function scanPublicRepoReadiness() { files.nodebenchGoal, files.runtimeGoal, files.routeHonestySpec, + files.scratchnodeWikiBridgeSpec, files.demoQa, files.license, files.security, @@ -648,6 +650,7 @@ function scanPublicRepoReadiness() { const readme = readText(files.readme); const homeHtml = readText(files.homeV5); const routeHonestySpec = readText(files.routeHonestySpec); + const scratchnodeWikiBridgeSpec = readText(files.scratchnodeWikiBridgeSpec); addCheck({ ok: /Explicit Exclusions/i.test(splitRunbook) && /convex\//i.test(splitRunbook), @@ -873,6 +876,22 @@ function scanPublicRepoReadiness() { plane: "event-log-evidence", detail: files.routeHonestySpec, }); + addCheck({ + ok: + /keeps public wiki bridge links visibility-safe and free of private handoff params/i.test( + scratchnodeWikiBridgeSpec, + ) && + /https:\/\/scratchnode\.live\/wiki\/rooftop-launch/i.test(scratchnodeWikiBridgeSpec) && + /https:\/\/scratchnode\.live\/e\/rooftop/i.test(scratchnodeWikiBridgeSpec) && + /not\.toContain\("token="\)/i.test(scratchnodeWikiBridgeSpec) && + /not\.toContain\("session="\)/i.test(scratchnodeWikiBridgeSpec) && + /not\.toContain\("continuation="\)/i.test(scratchnodeWikiBridgeSpec) && + /not\.toContain\("publicArtifact="\)/i.test(scratchnodeWikiBridgeSpec) && + /not\.toContain\("noteCount="\)/i.test(scratchnodeWikiBridgeSpec), + name: "NodeBench public wiki bridge links stay visibility-safe", + plane: "event-log-evidence", + detail: files.scratchnodeWikiBridgeSpec, + }); } function scanGoalAutomationReadiness() { diff --git a/src/features/events/views/ScratchnodeWikiBridge.test.tsx b/src/features/events/views/ScratchnodeWikiBridge.test.tsx index 126c7708..09257129 100644 --- a/src/features/events/views/ScratchnodeWikiBridge.test.tsx +++ b/src/features/events/views/ScratchnodeWikiBridge.test.tsx @@ -2,10 +2,10 @@ * Scenario tests for the ScratchNode -> NodeBench bridge receiving surface. * Persona: a guest who clicked "Continue in NodeBench" from a ScratchNode wiki. * Verifies the route renders the public recap, frames the conversion, stays - * honest on unpublished/loading, and SANITIZES the wiki body (XSS defense). + * honest on unpublished/loading, and sanitizes the wiki body (XSS defense). */ -import { render, screen, cleanup } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; const useQueryMock = vi.fn(); vi.mock("convex/react", () => ({ @@ -36,15 +36,13 @@ describe("ScratchnodeWikiBridge", () => { render(); expect(screen.getByTestId("scratchnode-wiki-bridge-body")).toHaveTextContent("PUBLIC_RECAP_BODY"); - // Conversion CTA points into the NodeBench app (a real, working route). const cta = screen.getByTestId("scratchnode-wiki-bridge-cta-nodebench"); expect(cta).toHaveAttribute("href", "/"); - // Reverse paths back to ScratchNode are present and well-formed. expect(screen.getByText("View the public wiki")).toHaveAttribute( "href", "https://scratchnode.live/wiki/rooftop-launch", ); - expect(screen.getByText("Open in ScratchNode →")).toHaveAttribute( + expect(screen.getByText(/Open in ScratchNode/)).toHaveAttribute( "href", "https://scratchnode.live/e/rooftop", ); @@ -60,14 +58,13 @@ describe("ScratchnodeWikiBridge", () => { ); }); - it("shows an honest empty state for an unpublished/unknown room (never a fake recap)", () => { + it("shows an honest empty state for an unpublished or unknown room", () => { useQueryMock.mockReturnValue(null); render(); expect(screen.getByTestId("scratchnode-wiki-bridge-empty")).toHaveTextContent( - "hasn’t published its wiki yet", + /hasn.t published its wiki yet/i, ); - // No recap body is fabricated. expect(screen.queryByTestId("scratchnode-wiki-bridge-body")).toBeNull(); }); @@ -75,13 +72,13 @@ describe("ScratchnodeWikiBridge", () => { useQueryMock.mockReturnValue(null); render(); - expect(screen.getByText("Open in ScratchNode →")).toHaveAttribute( + expect(screen.getByText(/Open in ScratchNode/)).toHaveAttribute( "href", "https://scratchnode.live/e/orbital", ); }); - it("shows a loading state while the query resolves (no premature empty/error)", () => { + it("shows a loading state while the query resolves", () => { useQueryMock.mockReturnValue(undefined); render(); @@ -89,7 +86,7 @@ describe("ScratchnodeWikiBridge", () => { expect(screen.queryByTestId("scratchnode-wiki-bridge-empty")).toBeNull(); }); - it("SANITIZES the wiki body — script/handlers are stripped before render (XSS defense)", () => { + it("sanitizes the wiki body before render", () => { useQueryMock.mockReturnValue({ ...WIKI, bodyHtml: @@ -99,9 +96,35 @@ describe("ScratchnodeWikiBridge", () => { const body = screen.getByTestId("scratchnode-wiki-bridge-body"); expect(body).toHaveTextContent("KEEP_THIS"); - // The dangerous bits never reach the DOM. expect(container.querySelector("script")).toBeNull(); expect(body.innerHTML).not.toContain("onerror"); expect(body.innerHTML).not.toContain("window.__xss"); }); + + it("keeps public wiki bridge links visibility-safe and free of private handoff params", () => { + useQueryMock.mockReturnValue(WIKI); + render( + , + ); + + const publicWikiHref = screen.getByText("View the public wiki").getAttribute("href"); + const roomHref = screen.getByText(/Open in ScratchNode/).getAttribute("href"); + + expect(publicWikiHref).toBe("https://scratchnode.live/wiki/rooftop-launch"); + expect(roomHref).toBe("https://scratchnode.live/e/rooftop"); + + for (const href of [publicWikiHref, roomHref]) { + expect(href).not.toContain("token="); + expect(href).not.toContain("session="); + expect(href).not.toContain("source="); + expect(href).not.toContain("room="); + expect(href).not.toContain("continuation="); + expect(href).not.toContain("publicArtifact="); + expect(href).not.toContain("noteCount="); + } + }); }); From cdefb0dc68fb6bd0004a38177749b7b62b3f6e3a Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 07:37:39 -0700 Subject: [PATCH 71/91] test: guard host announcement boundary --- convex/events.runtime-boundary.test.ts | 21 +++++++++++++++++++++ scripts/scratchnode/scanLaunch.mjs | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/convex/events.runtime-boundary.test.ts b/convex/events.runtime-boundary.test.ts index 20041fb7..3c0f1e44 100644 --- a/convex/events.runtime-boundary.test.ts +++ b/convex/events.runtime-boundary.test.ts @@ -83,6 +83,27 @@ describe("scratchnode public runtime boundaries", () => { expect(sendMessage).not.toContain("anchorType"); }); + it("keeps host announcements as host-gated no-LLM event-log messages", () => { + const sendMessage = functionBlock("sendMessage"); + const executableSendMessage = sendMessage + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n\r]*/g, ""); + + expect(sendMessage).toContain('v.literal("system")'); + expect(sendMessage).toContain('args.kind === "system"'); + expect(sendMessage).toContain("Host ownership is required for system messages."); + expect(sendMessage).toContain("requireHost(ctx, args.eventId, args.ownerKey)"); + expect(sendMessage).toContain('ctx.db.insert("liveEventMessages"'); + expect(sendMessage).toContain("kind: args.kind"); + expect(sendMessage).toContain("lastActivityAt: now"); + + expect(executableSendMessage).not.toContain("askAgent"); + expect(executableSendMessage).not.toContain("composeAnswer"); + expect(executableSendMessage).not.toContain("liveEventAnswers"); + expect(executableSendMessage).not.toContain("liveEventWikiVersions"); + expect(executableSendMessage).not.toContain("userNotes"); + }); + it("keeps ScratchNode account event state as bounded joined and hosted lists", () => { const getMyEvents = functionBlock("getMyEvents", "query"); diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index a56bb284..431bf787 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -29,6 +29,7 @@ const files = { scratchnodeEventLogGoal: "goals/scratchnode/004-event-log-followups.md", nodebenchGoal: "goals/nodebench/001-event-handoff.md", runtimeGoal: "goals/runtime/001-public-private-boundary.md", + eventsRuntimeBoundarySpec: "convex/events.runtime-boundary.test.ts", routeHonestySpec: "tests/e2e/scratchnode-live-route-honesty.spec.ts", scratchnodeWikiBridgeSpec: "src/features/events/views/ScratchnodeWikiBridge.test.tsx", demoQa: "qa/run_demo_full.md", @@ -540,6 +541,7 @@ function scanBackendContracts() { const events = readText(files.events); const notes = readText(files.notes); const users = readText(files.users); + const eventsRuntimeBoundarySpec = readText(files.eventsRuntimeBoundarySpec); const contracts = [ { @@ -583,6 +585,22 @@ function scanBackendContracts() { path: files.events, blocker: true, }, + { + name: "host announcements stay host-gated no-LLM event-log messages", + ok: + /kind:\s*v\.union\([\s\S]*v\.literal\("system"\)/i.test(events) && + /args\.kind === "system"[\s\S]{0,360}requireHost\(ctx,\s*args\.eventId,\s*args\.ownerKey\)/i.test(events) && + /ctx\.db\.insert\("liveEventMessages"[\s\S]{0,260}kind:\s*args\.kind/i.test(events) && + /keeps host announcements as host-gated no-LLM event-log messages/i.test( + eventsRuntimeBoundarySpec, + ) && + /expect\(executableSendMessage\)\.not\.toContain\("askAgent"\)/i.test(eventsRuntimeBoundarySpec) && + /expect\(executableSendMessage\)\.not\.toContain\("liveEventWikiVersions"\)/i.test( + eventsRuntimeBoundarySpec, + ), + path: files.eventsRuntimeBoundarySpec, + blocker: true, + }, { name: "host-only wiki promotion/publish gate exists", ok: /(?:const|function)\s+requireHost/i.test(events) && /promoteAnswerToFaq/i.test(events) && /publishWiki/i.test(events), From 6818ed1957fadfddbcd515186f1fe272705a344d Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 07:49:11 -0700 Subject: [PATCH 72/91] test: guard event check-in boundary --- convex/events.runtime-boundary.test.ts | 23 +++++++++++++++++++++++ scripts/scratchnode/scanLaunch.mjs | 19 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/convex/events.runtime-boundary.test.ts b/convex/events.runtime-boundary.test.ts index 3c0f1e44..91ceb3fa 100644 --- a/convex/events.runtime-boundary.test.ts +++ b/convex/events.runtime-boundary.test.ts @@ -104,6 +104,29 @@ describe("scratchnode public runtime boundaries", () => { expect(executableSendMessage).not.toContain("userNotes"); }); + it("keeps attendee check-ins as no-LLM membership event-log moments", () => { + const joinEvent = functionBlock("joinEvent"); + const executableJoinEvent = joinEvent + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n\r]*/g, ""); + + expect(joinEvent).toContain('export const joinEvent = mutation({'); + expect(joinEvent).toContain("liveEventMembers"); + expect(joinEvent).toContain("by_event_session"); + expect(joinEvent).toContain('ctx.db.insert("liveEventMembers"'); + expect(joinEvent).toContain("lastSeenAt: now"); + expect(joinEvent).toContain("lastActivityAt: now"); + expect(joinEvent).toContain("liveEventJoinRequests"); + expect(joinEvent).toContain('request.status !== "approved"'); + + expect(executableJoinEvent).not.toContain("askAgent"); + expect(executableJoinEvent).not.toContain("composeAnswer"); + expect(executableJoinEvent).not.toContain("liveEventMessages"); + expect(executableJoinEvent).not.toContain("liveEventAnswers"); + expect(executableJoinEvent).not.toContain("liveEventWikiVersions"); + expect(executableJoinEvent).not.toContain("userNotes"); + }); + it("keeps ScratchNode account event state as bounded joined and hosted lists", () => { const getMyEvents = functionBlock("getMyEvents", "query"); diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 431bf787..26407223 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -601,6 +601,25 @@ function scanBackendContracts() { path: files.eventsRuntimeBoundarySpec, blocker: true, }, + { + name: "attendee check-ins stay no-LLM membership event-log moments", + ok: + /export const joinEvent = mutation/i.test(events) && + /ctx\.db\.insert\("liveEventMembers"[\s\S]{0,220}lastSeenAt:\s*now/i.test(events) && + /ctx\.db\.patch\(event\._id,\s*\{\s*lastActivityAt:\s*now\s*\}\)/i.test(events) && + /liveEventJoinRequests[\s\S]{0,360}request\.status !== "approved"/i.test(events) && + /keeps attendee check-ins as no-LLM membership event-log moments/i.test( + eventsRuntimeBoundarySpec, + ) && + /expect\(executableJoinEvent\)\.not\.toContain\("askAgent"\)/i.test( + eventsRuntimeBoundarySpec, + ) && + /expect\(executableJoinEvent\)\.not\.toContain\("liveEventMessages"\)/i.test( + eventsRuntimeBoundarySpec, + ), + path: files.eventsRuntimeBoundarySpec, + blocker: true, + }, { name: "host-only wiki promotion/publish gate exists", ok: /(?:const|function)\s+requireHost/i.test(events) && /promoteAnswerToFaq/i.test(events) && /publishWiki/i.test(events), From 78501b4c8949da1f896ec6187121968d0485769b Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 08:00:42 -0700 Subject: [PATCH 73/91] feat: trace live assist followup cues --- public/proto/home-v5.html | 9 +++++++-- scripts/scratchnode/scanLaunch.mjs | 5 +++++ tests/e2e/scratchnode-live-route-honesty.spec.ts | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 088c4d6a..7ab248f4 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -6711,7 +6711,8 @@

Keyboard shortcuts

id: _laNextId('cue'), text: text, ts: Date.now(), - source: opts.source || 'agent' + source: opts.source || 'agent', + skill: opts.skill || 'meeting-live-cue' }; window._live_assist.cues.unshift(cue); // newest on top // Per spec: 1-3 cards visible. Hard cap at 3 to enforce reduction @@ -6734,7 +6735,7 @@

Keyboard shortcuts

traceRef: traceRef, producedBy: { runId: 'run_' + cue.id, - skill: opts.skill || 'meeting-live-cue', + skill: cue.skill, toolChain: opts.toolChain || ['retrieve_event_context'] }, version: { memorySnapshotVersion: 1 }, @@ -6942,6 +6943,8 @@

Keyboard shortcuts

} function buildLiveAssistFollowUpNote(cue) { var text = cue && cue.text ? String(cue.text) : 'Follow up on this event cue'; + var source = cue && cue.source ? String(cue.source) : ''; + var skill = cue && cue.skill ? String(cue.skill) : ''; var topic = window._live_assist && window._live_assist.currentTopic ? window._live_assist.currentTopic : null; @@ -6957,6 +6960,8 @@

Keyboard shortcuts

]; if (topic && topic.text) lines.push('Event topic: ' + topic.text + (topic.meta ? ' - ' + topic.meta : '')); if (context.length) lines.push('Context: ' + context.join(', ')); + if (source) lines.push('Cue source: ' + source); + if (skill) lines.push('Cue skill: ' + skill); lines.push('Visibility: private follow-up note; not public chat or public /ask.'); return lines.join('\n'); } diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 26407223..e1e73672 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -804,6 +804,9 @@ function scanPublicRepoReadiness() { /function saveLiveAssistPrivateNote\s*\(/i.test(homeHtml) && /window\.saveLiveAssistPrivateNote\s*=\s*saveLiveAssistPrivateNote/i.test(homeHtml) && /saveLiveAssistPrivateNote\('Cue: '\s*\+\s*cue\.text,\s*'cue'\)/i.test(homeHtml) && + /skill:\s*opts\.skill\s*\|\|\s*'meeting-live-cue'/i.test(homeHtml) && + /Cue source: '\s*\+\s*source/i.test(homeHtml) && + /Cue skill: '\s*\+\s*skill/i.test(homeHtml) && /Visibility: private follow-up note; not public chat or public \/ask\./i.test(homeHtml) && /Live Assist save cue writes an actual private note without public writes/i.test( routeHonestySpec, @@ -824,6 +827,8 @@ function scanPublicRepoReadiness() { ) && /_laCueAction\?\.\("followup",\s*id\)/i.test(routeHonestySpec) && /Follow-up: \$\{cueText\}/i.test(routeHonestySpec) && + /Cue source: route-test/i.test(routeHonestySpec) && + /Cue skill: follow-up-depth/i.test(routeHonestySpec) && /publicSendCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), name: "event-log route spec covers structured private follow-up cues", plane: "event-log-evidence", diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 734b106c..976c7878 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -1072,6 +1072,8 @@ test.describe("ScratchNode live route honesty", () => { expect(state.noteText).toContain("Evidence to capture: quote, speaker/company"); expect(state.noteText).toContain("Event topic: MCP auth - Panel Room A"); expect(state.noteText).toContain("Context: @Orbital Labs, @Alex Chen, [[tenant RBAC]]"); + expect(state.noteText).toContain("Cue source: route-test"); + expect(state.noteText).toContain("Cue skill: follow-up-depth"); expect(state.noteText).toContain("Visibility: private follow-up note; not public chat or public /ask."); expect(state.recentNotes.join("\n")).toContain(`Follow-up: ${cueText}`); expect(state.actions).toEqual([]); From 22340aab1258a594d469704bae54e35878bb54eb Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 08:11:44 -0700 Subject: [PATCH 74/91] feat: carry followup cue trace --- public/proto/home-v5.html | 13 ++++++++----- scripts/scratchnode/scanLaunch.mjs | 3 +++ tests/e2e/scratchnode-live-route-honesty.spec.ts | 1 + 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 7ab248f4..7d8bde0b 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -6707,12 +6707,14 @@

Keyboard shortcuts

// Add a cue card. Returns the cue id. function pushLiveAssistCue(text, opts) { opts = opts || {}; + var cueId = _laNextId('cue'); var cue = { - id: _laNextId('cue'), + id: cueId, text: text, ts: Date.now(), source: opts.source || 'agent', - skill: opts.skill || 'meeting-live-cue' + skill: opts.skill || 'meeting-live-cue', + traceRef: 'trace_' + cueId }; window._live_assist.cues.unshift(cue); // newest on top // Per spec: 1-3 cards visible. Hard cap at 3 to enforce reduction @@ -6722,17 +6724,16 @@

Keyboard shortcuts

if (typeof window.recordAgentOutputEnvelope === 'function') { var trigger = opts.trigger || _inferLiveCueTrigger(text); var l3 = opts.l3 || _inferLiveCueL3(text); - var traceRef = 'trace_' + cue.id; window.recordAgentOutputEnvelope({ id: 'out_' + cue.id, l1: 'private_memory', l2: 'live_cue', l3: l3, - target: { eventId: 'evt_ai_infra_summit', messageId: opts.messageId || undefined, traceId: traceRef }, + target: { eventId: 'evt_ai_infra_summit', messageId: opts.messageId || undefined, traceId: cue.traceRef }, visibility: opts.visibility || 'private', sourceRefs: opts.sourceRefs || ['event:evt_ai_infra_summit'], citationRefs: opts.citationRefs || [], - traceRef: traceRef, + traceRef: cue.traceRef, producedBy: { runId: 'run_' + cue.id, skill: cue.skill, @@ -6945,6 +6946,7 @@

Keyboard shortcuts

var text = cue && cue.text ? String(cue.text) : 'Follow up on this event cue'; var source = cue && cue.source ? String(cue.source) : ''; var skill = cue && cue.skill ? String(cue.skill) : ''; + var traceRef = cue && cue.traceRef ? String(cue.traceRef) : ''; var topic = window._live_assist && window._live_assist.currentTopic ? window._live_assist.currentTopic : null; @@ -6962,6 +6964,7 @@

Keyboard shortcuts

if (context.length) lines.push('Context: ' + context.join(', ')); if (source) lines.push('Cue source: ' + source); if (skill) lines.push('Cue skill: ' + skill); + if (traceRef) lines.push('Cue trace: ' + traceRef); lines.push('Visibility: private follow-up note; not public chat or public /ask.'); return lines.join('\n'); } diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index e1e73672..d974164c 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -805,8 +805,10 @@ function scanPublicRepoReadiness() { /window\.saveLiveAssistPrivateNote\s*=\s*saveLiveAssistPrivateNote/i.test(homeHtml) && /saveLiveAssistPrivateNote\('Cue: '\s*\+\s*cue\.text,\s*'cue'\)/i.test(homeHtml) && /skill:\s*opts\.skill\s*\|\|\s*'meeting-live-cue'/i.test(homeHtml) && + /traceRef:\s*'trace_'\s*\+\s*cueId/i.test(homeHtml) && /Cue source: '\s*\+\s*source/i.test(homeHtml) && /Cue skill: '\s*\+\s*skill/i.test(homeHtml) && + /Cue trace: '\s*\+\s*traceRef/i.test(homeHtml) && /Visibility: private follow-up note; not public chat or public \/ask\./i.test(homeHtml) && /Live Assist save cue writes an actual private note without public writes/i.test( routeHonestySpec, @@ -829,6 +831,7 @@ function scanPublicRepoReadiness() { /Follow-up: \$\{cueText\}/i.test(routeHonestySpec) && /Cue source: route-test/i.test(routeHonestySpec) && /Cue skill: follow-up-depth/i.test(routeHonestySpec) && + /Cue trace: trace_\$\{cueId\}/i.test(routeHonestySpec) && /publicSendCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), name: "event-log route spec covers structured private follow-up cues", plane: "event-log-evidence", diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 976c7878..ff815c96 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -1074,6 +1074,7 @@ test.describe("ScratchNode live route honesty", () => { expect(state.noteText).toContain("Context: @Orbital Labs, @Alex Chen, [[tenant RBAC]]"); expect(state.noteText).toContain("Cue source: route-test"); expect(state.noteText).toContain("Cue skill: follow-up-depth"); + expect(state.noteText).toContain(`Cue trace: trace_${cueId}`); expect(state.noteText).toContain("Visibility: private follow-up note; not public chat or public /ask."); expect(state.recentNotes.join("\n")).toContain(`Follow-up: ${cueText}`); expect(state.actions).toEqual([]); From c27da56767a34cad7e7b969b1e5336a7a47bf7c9 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 08:21:07 -0700 Subject: [PATCH 75/91] fix: trust redeemed scratchnode room --- .../views/ScratchnodePrivateBridge.test.tsx | 22 +++++++++++++++++++ .../events/views/ScratchnodePrivateBridge.tsx | 4 +++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/features/events/views/ScratchnodePrivateBridge.test.tsx b/src/features/events/views/ScratchnodePrivateBridge.test.tsx index a274911a..875b56cf 100644 --- a/src/features/events/views/ScratchnodePrivateBridge.test.tsx +++ b/src/features/events/views/ScratchnodePrivateBridge.test.tsx @@ -72,6 +72,28 @@ describe("ScratchnodePrivateBridge", () => { expect(document.body.innerHTML).not.toContain("opaque-token-abcdefghijklmno"); }); + it("prefers the redeemed room code over a stale query room when building the ScratchNode return link", async () => { + consumeMock.mockResolvedValue(RESULT); + render( + , + ); + + await waitFor(() => + expect(screen.getByTestId("scratchnode-private-bridge-note-body")).toHaveTextContent( + "PRIVATE_NOTE_BODY", + ), + ); + + expect(screen.getByText(/Back to ScratchNode/i)).toHaveAttribute( + "href", + "https://scratchnode.live/e/rooftop", + ); + }); + it("shows a loading state while redeeming (no premature empty/error)", () => { // Never resolves — stays in the redeeming phase. consumeMock.mockReturnValue(new Promise(() => {})); diff --git a/src/features/events/views/ScratchnodePrivateBridge.tsx b/src/features/events/views/ScratchnodePrivateBridge.tsx index 70ad041e..f6a4cec1 100644 --- a/src/features/events/views/ScratchnodePrivateBridge.tsx +++ b/src/features/events/views/ScratchnodePrivateBridge.tsx @@ -175,8 +175,10 @@ export function ScratchnodePrivateBridge({ slug, token, roomCode }: Props) { // consume identity is stable; token drives the single redemption. }, [token, consume]); + // Once the token redeems, trust the backend-bound room code over the inbound + // query param so a stale/tampered `?room=` cannot misdirect the return link. const roomUrl = `${SCRATCHNODE_ORIGIN}/e/${encodeURIComponent( - String(roomCode || result?.roomCode || slug).toLowerCase(), + String(result?.roomCode || roomCode || slug).toLowerCase(), )}`; return ( From 7206f258bf7162194f4c7c7563c3abba50925691 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 08:30:01 -0700 Subject: [PATCH 76/91] fix: wait for wiki bridge state --- scripts/scratchnode/scanLaunch.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index d974164c..1d2c9a47 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -2009,7 +2009,13 @@ async function runInteractiveChecks() { "https://nodebenchai.com/events/not-published/wiki?source=scratchnode&room=ORBITAL", async (page) => { await page.waitForSelector("body", { timeout: 15_000 }); - await page.waitForTimeout(1200); + await page.waitForFunction( + () => + !!document.querySelector('[data-testid="scratchnode-wiki-bridge-empty"]') || + !!document.querySelector('[data-testid="scratchnode-wiki-bridge-body"]'), + null, + { timeout: 12_000 }, + ).catch(() => undefined); const data = await page.evaluate(() => { const text = (document.body.textContent ?? "").replace(/\s+/g, " ").trim(); const scratchnodeLinks = [...document.querySelectorAll("a")] From 85879d76cdb2c7ed7c1af1d309028e7382ad5538 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 08:51:36 -0700 Subject: [PATCH 77/91] test: guard answer anchor followups --- scripts/scratchnode/scanLaunch.mjs | 11 ++ .../scratchnode-live-route-honesty.spec.ts | 171 ++++++++++++++++++ 2 files changed, 182 insertions(+) diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 1d2c9a47..41674118 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -861,6 +861,17 @@ function scanPublicRepoReadiness() { plane: "event-log-evidence", detail: files.routeHonestySpec, }); + addCheck({ + ok: + /private notes anchored from public answers preserve context without public leakage/i.test(routeHonestySpec) && + /targetKind:\s*"answer"/i.test(routeHonestySpec) && + /targetAnswerId:\s*answerId/i.test(routeHonestySpec) && + /sn-anchor-pin/i.test(routeHonestySpec) && + /serializedAnswers\)\.not\.toContain\(privateText\)/i.test(routeHonestySpec), + name: "event-log route spec covers private note answer anchors", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); addCheck({ ok: /verified host publishes promoted public answers into the wiki without leaking private notes/i.test( diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index ff815c96..5180be30 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -34,6 +34,8 @@ async function fulfillScratchNodePage( window.__snMockMutations = []; window.__snMockMessages = []; window.__snMockAnswers = []; + window.__snMockNotes = []; + window.__snMockAnchors = []; window.__snMockActions = []; window.__snMockPromotedAnswerIds = []; window.__snMockPublishedWiki = null; @@ -189,6 +191,50 @@ async function fulfillScratchNodePage( requestId: 'liveEventJoinRequests:1', }); } + if (name === 'notes:createNote') { + const noteId = 'notes:' + (window.__snMockNotes.length + 1); + const note = { + _id: noteId, + eventId: args.eventId, + ownerKey: args.ownerKey, + title: args.title || 'Untitled', + bodyHtml: args.bodyHtml || '', + tags: args.tags || [], + isAsk: !!args.isAsk, + pinned: !!args.pinned, + anchorType: args.anchorType, + anchorId: args.anchorId, + anchorLabel: args.anchorLabel, + anchorPreview: args.anchorPreview, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + window.__snMockNotes.push(note); + const notifyNotes = window.__snMockSubscriptions['notes:listMyNotes']; + if (typeof notifyNotes === 'function') { + notifyNotes(window.__snMockNotes.slice()); + } + return Promise.resolve({ ok: true, noteId }); + } + if (name === 'notes:createNoteAnchor') { + const anchorId = 'noteAnchors:' + (window.__snMockAnchors.length + 1); + const anchor = { + _id: anchorId, + ownerKey: args.ownerKey, + eventId: args.eventId, + noteId: args.noteId, + targetKind: args.targetKind, + targetMessageId: args.targetMessageId, + targetAnswerId: args.targetAnswerId, + createdAt: Date.now(), + }; + window.__snMockAnchors.push(anchor); + const notifyAnchors = window.__snMockSubscriptions['notes:listMyAnchors']; + if (typeof notifyAnchors === 'function') { + notifyAnchors({ anchors: window.__snMockAnchors.slice() }); + } + return Promise.resolve({ ok: true, anchorId }); + } return Promise.resolve({}); } query(name) { @@ -272,6 +318,12 @@ async function fulfillScratchNodePage( const iv = setInterval(tick, 50); return () => clearInterval(iv); } + if (name === 'notes:listMyNotes') { + setTimeout(() => cb(window.__snMockNotes.slice()), 0); + } + if (name === 'notes:listMyAnchors') { + setTimeout(() => cb({ anchors: window.__snMockAnchors.slice() }), 0); + } } } `, @@ -808,6 +860,125 @@ test.describe("ScratchNode live route honesty", () => { expect(anchorState.markerCount).toBe(1); }); + test("private notes anchored from public answers preserve context without public leakage", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const publicPrompt = "which MCP auth answer should get a private follow-up?"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicPrompt); + + const answerCard = page.locator(".ans").filter({ hasText: publicPrompt }).first(); + await expect(answerCard).toContainText("Mock sourced answer for " + publicPrompt); + await expect(answerCard).toContainText("private notes excluded"); + const answerId = await answerCard.getAttribute("data-answer-id"); + expect(answerId).toBe("liveEventAnswers:1"); + if (!answerId) throw new Error("Expected answer id"); + + await page.evaluate((id) => { + (window as any).snAnchorTo?.("answer", id); + (window as any).toggleLock?.(); + }, answerId); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + + const privateText = "private answer follow-up: ask Alex for the source provenance risk"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: privateText })).toHaveCount(0); + await expect(answerCard.locator(".sn-anchor-pin")).toHaveAttribute( + "aria-label", + "Open anchored private note", + ); + + const anchorState = await page.evaluate( + ({ answerId, privateText, publicPrompt }) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(privateText), + ); + return { + note, + pendingAnchor: win._sn_pending_anchor, + anchorCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "notes:createNoteAnchor", + ), + publicAskCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === publicPrompt && + call.args?.kind === "ask", + ), + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === privateText, + ), + actions: win.__snMockActions || [], + serializedAnswers: JSON.stringify(win.__snMockAnswers || []), + markerCount: document.querySelectorAll( + `.ans[data-answer-id="${answerId}"] .sn-anchor-pin`, + ).length, + targetAnchors: Array.from(win._sn_anchors_by_target?.keys?.() || []), + }; + }, + { answerId, privateText, publicPrompt }, + ); + + expect(anchorState.note).toEqual( + expect.objectContaining({ + id: "notes:1", + }), + ); + expect(anchorState.pendingAnchor).toBeNull(); + expect(anchorState.anchorCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + eventId: "liveEvents:1", + noteId: "notes:1", + targetKind: "answer", + targetAnswerId: answerId, + }), + }), + ]); + expect(anchorState.anchorCalls[0].args.targetMessageId).toBeUndefined(); + expect(anchorState.publicAskCalls).toHaveLength(1); + expect(anchorState.privateSendCalls).toEqual([]); + expect(anchorState.actions).toEqual([ + expect.objectContaining({ + name: "events:askAgent", + args: expect.objectContaining({ + question: publicPrompt, + }), + }), + ]); + expect(anchorState.serializedAnswers).not.toContain(privateText); + expect(anchorState.markerCount).toBe(1); + expect(anchorState.targetAnchors).toContain(`answer:${answerId}`); + }); + test("sensitive event mode forces /ask into private notes without agent calls", async ({ page }) => { await fulfillScratchNodePage(page); From e03351b84409ba0b539990d72af30bb6bdd5a82e Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 09:04:47 -0700 Subject: [PATCH 78/91] test: deepen private handoff followups --- public/proto/home-v5.html | 2 +- scripts/scratchnode/scanLaunch.mjs | 5 +++-- tests/e2e/scratchnode-live-route-honesty.spec.ts | 3 +++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 7d8bde0b..8e262b58 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -4971,7 +4971,7 @@

Keyboard shortcuts

'
' + renderNotesEditor() + '
' + '
' + '

Continue privately

' + - '

Open NodeBench as the private handoff: public wiki context plus your private notebook for research, reports, and follow-ups.

' + + '

Open NodeBench as the private handoff: public wiki context plus your private notebook for deeper research, reports, and follow-ups across people, companies, topics, and anchors.

' + '' + '
' + buildNodeBenchEventPrivateUrl() + '
'; }, diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 41674118..16d3a474 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -348,12 +348,13 @@ function scanHomeV5() { /WORKSPACE_BASE_URL[\s\S]{0,220}['"]\/events\/['"][\s\S]{0,220}['"]\/private['"]/.test(html) && /\?token=['"]?\s*\+\s*encodeURIComponent\(token\)/.test(html) && /scratchnodeHandoff:mintEventHandoffToken/.test(html) && - /function\s+openNodeBenchPrivateHandoff\s*\(/.test(html); + /function\s+openNodeBenchPrivateHandoff\s*\(/.test(html) && + /deeper research, reports, and follow-ups across people, companies, topics, and anchors/i.test(html); addCheck({ ok: hasPrivateHandoffContract, name: "ScratchNode private handoff targets NodeBench event artifact", plane: "nodebench-handoff", - detail: "tokenized /events/:slug/private success path plus /scratchnode-events honest fallback", + detail: "tokenized /events/:slug/private success path plus /scratchnode-events honest fallback and deeper follow-up copy", }); if (!hasPrivateHandoffContract) { addFinding({ diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 5180be30..9908fd87 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -1389,6 +1389,9 @@ test.describe("ScratchNode live route honesty", () => { await page.evaluate(() => (window as any).openNotes?.()); await expect(page.locator("#sheet-title")).toContainText("My notes"); await expect(page.locator("#sheet-content")).toContainText(privatePrompt); + await expect(page.locator("#sheet-content")).toContainText( + "deeper research, reports, and follow-ups across people, companies, topics, and anchors", + ); await expect(page.locator("#sheet-content")).toContainText("Open NodeBench event notebook"); await expect(page.locator("#sn-nodebench-private-handoff")).toBeVisible(); }); From 2f9f06082633f6d441dbd8e739e33b77221d54f7 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 09:20:58 -0700 Subject: [PATCH 79/91] test: guard voice transcript export projection --- docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md | 2 +- scripts/repo/export-scratchnode-live-public.mjs | 3 ++- scripts/scratchnode/scanLaunch.mjs | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md b/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md index 25944d38..4a36c819 100644 --- a/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md +++ b/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md @@ -145,7 +145,7 @@ The script: Exported `contracts/scratchnode-live-api.json` must keep two projections explicit: - `publicEventLogJson`: public event metadata, public chat, public `/ask` answers, host-promoted wiki sections, public sources, and typed manual location spots. It excludes private notes, owner keys, session ids, handoff tokens, and NodeBench workspace artifacts. -- `ownerPrivateNoteProjection`: owner-only private notes, anchors, follow-ups, and NodeBench handoff context. It excludes public wiki JSON, public `/ask` cache, public answer traces, and other attendees' notes. +- `ownerPrivateNoteProjection`: owner-only private notes, anchors, follow-ups, voice transcripts, and NodeBench handoff context. It excludes public wiki JSON, public `/ask` cache, public answer traces, and other attendees' notes. ## Backend Compatibility Rule diff --git a/scripts/repo/export-scratchnode-live-public.mjs b/scripts/repo/export-scratchnode-live-public.mjs index 64b852f8..693d3a6c 100644 --- a/scripts/repo/export-scratchnode-live-public.mjs +++ b/scripts/repo/export-scratchnode-live-public.mjs @@ -440,6 +440,7 @@ function buildApiContract() { "owner private notes", "private note anchors", "private follow-ups", + "owner voice transcripts", "NodeBench handoff context", ], excludes: [ @@ -512,7 +513,7 @@ if (publicLog.visibility !== "public" || missingPublicExclusions.length) { console.error("Public event-log projection contract is incomplete."); process.exit(1); } -const requiredPrivateIncludes = ["owner private notes", "private note anchors", "private follow-ups", "NodeBench handoff context"]; +const requiredPrivateIncludes = ["owner private notes", "private note anchors", "private follow-ups", "owner voice transcripts", "NodeBench handoff context"]; const requiredPrivateExclusions = ["public wiki JSON", "public /ask cache", "public answer traces", "other attendees' notes"]; const missingPrivateIncludes = requiredPrivateIncludes.filter((entry) => !(privateProjection.includes || []).includes(entry)); const missingPrivateExclusions = requiredPrivateExclusions.filter((entry) => !(privateProjection.excludes || []).includes(entry)); diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 16d3a474..011c81b7 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -721,6 +721,7 @@ function scanPublicRepoReadiness() { /owner private notes/i.test(exportScript) && /private note anchors/i.test(exportScript) && /private follow-ups/i.test(exportScript) && + /owner voice transcripts/i.test(exportScript) && /NodeBench handoff context/i.test(exportScript) && /public \/ask cache/i.test(exportScript) && /other attendees' notes/i.test(exportScript), From 192e4c085cea1f192e1afff3fff6958055d070bb Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 09:31:23 -0700 Subject: [PATCH 80/91] Add manual location anchor evidence --- AGENT_COORDINATION.md | 2 + scripts/scratchnode/scanLaunch.mjs | 5 + .../scratchnode-live-route-honesty.spec.ts | 100 ++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index 0dc188b5..c173a639 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -110,6 +110,8 @@ Keep entries short and honest. Newest on top within each section. ## Recently shipped (this ScratchNode session) +- **Codex** - manual location spot anchor proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#manual-location-spots` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves a private note anchored from a public Booth 12 location moment preserves context, stays out of public chat and public `/ask`, and renders only the owner-visible marker; launch scanner now requires the proof before passing. + - **Codex** - visibility-safe NodeBench handoff proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#nodebench-handoff` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves private follow-up text, tags, note ids, anchor ids/previews, public anchor text, and session ids stay out of fallback/tokenized handoff URLs; launch scanner now requires the proof before passing. - **Claude** — public `/wiki/` reader (`home-v5.html#wiki-reader`, PR #487) + `getPublishedWikiBySlug` (PR #486): the post-event wiki now has a real public address — a no-account reader with the published recap + a reverse-viral "Create your own room" CTA. `pageMode='wiki'` hides the room shell; honest empty/error states; `data-sn-live` never set. Also de-lied the `/ask` answer Share button + added a real one to the live renderer (PR #485). 3 wiki e2e + 6 backend scenarios + 20 honesty suite green. diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 011c81b7..fbe9dfe3 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -784,6 +784,11 @@ function scanPublicRepoReadiness() { /Investor Lounge/i.test(routeHonestySpec) && /Afterparty/i.test(routeHonestySpec) && /data-location-spot/i.test(routeHonestySpec) && + /private notes anchored from manual location spots preserve context without public leakage/i.test( + routeHonestySpec, + ) && + /locationMarkerCount/i.test(routeHonestySpec) && + /anchorPreview:\s*publicText/i.test(routeHonestySpec) && /navigator\.geolocation|getCurrentPosition|watchPosition/i.test(routeHonestySpec), name: "event-log route spec covers manual location spot fixtures", plane: "event-log-evidence", diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 9908fd87..b36e04c0 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -766,6 +766,106 @@ test.describe("ScratchNode live route honesty", () => { expect(state.publicSendCalls[0].args.kind).toBe("chat"); }); + test("private notes anchored from manual location spots preserve context without public leakage", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const publicText = "Meet at Booth 12 before the MCP auth panel"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicText); + + const publicRow = page.locator('.row[data-location-spot="Booth 12"]', { + hasText: publicText, + }); + await expect(publicRow.locator(".row-text")).toContainText(publicText); + await expect(publicRow.locator(".sn-location-spot")).toHaveText("at Booth 12"); + const messageId = await publicRow.getAttribute("data-mid"); + expect(messageId).toMatch(/^liveEventMessages:/); + + await page.evaluate((mid) => { + (window as any).noteOnMessage?.(mid); + }, messageId); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "true"); + await expect(page.locator("#reply-ctx-quote")).toContainText(publicText); + + const privateText = "private booth follow-up: ask Sarah which sponsor owns Booth 12"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "false"); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: privateText })).toHaveCount(0); + await expect(publicRow.locator(".private-note-marker")).toHaveAttribute( + "aria-label", + "1 private note anchored here", + ); + + const anchorState = await page.evaluate( + ({ privateText, publicText, messageId }) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(privateText), + ); + return { + note, + actions: win.__snMockActions || [], + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && call.args?.text === privateText, + ), + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === publicText && + call.args?.kind === "chat", + ), + markerCount: document.querySelectorAll( + `.row[data-mid="${messageId}"] .private-note-marker`, + ).length, + locationMarkerCount: document.querySelectorAll( + `.row[data-location-spot="Booth 12"] .private-note-marker`, + ).length, + }; + }, + { privateText, publicText, messageId }, + ); + + expect(anchorState.note).toEqual( + expect.objectContaining({ + anchorType: "message", + anchorId: messageId, + anchorPreview: publicText, + }), + ); + expect(anchorState.actions).toEqual([]); + expect(anchorState.privateSendCalls).toEqual([]); + expect(anchorState.publicSendCalls).toHaveLength(1); + expect(anchorState.markerCount).toBe(1); + expect(anchorState.locationMarkerCount).toBe(1); + }); + test("private notes anchored from public messages preserve context without public leakage", async ({ page, }) => { From 790e204d69604765a43e1ec0e5d815af7d42d660 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 09:51:55 -0700 Subject: [PATCH 81/91] test: guard tagged anchor followups --- AGENT_COORDINATION.md | 2 + scripts/scratchnode/scanLaunch.mjs | 6 + .../scratchnode-live-route-honesty.spec.ts | 138 ++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index c173a639..1aaafe9d 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -110,6 +110,8 @@ Keep entries short and honest. Newest on top within each section. ## Recently shipped (this ScratchNode session) +- **Codex** - tagged public-row anchor proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#tag-anchor` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves a private note anchored from a public @person/#company event-log row preserves the full public anchor preview, renders only the owner-visible marker, and keeps private person/company follow-up text out of public rows, public `/ask`, and serialized answers; launch scanner now requires the proof before passing. + - **Codex** - manual location spot anchor proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#manual-location-spots` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves a private note anchored from a public Booth 12 location moment preserves context, stays out of public chat and public `/ask`, and renders only the owner-visible marker; launch scanner now requires the proof before passing. - **Codex** - visibility-safe NodeBench handoff proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#nodebench-handoff` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves private follow-up text, tags, note ids, anchor ids/previews, public anchor text, and session ids stay out of fallback/tokenized handoff URLs; launch scanner now requires the proof before passing. diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index fbe9dfe3..08f90c0c 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -799,7 +799,13 @@ function scanPublicRepoReadiness() { /typed people and company tags stay public-row context while private tagged follow-ups stay private/i.test( routeHonestySpec, ) && + /private notes anchored from people and company tags keep public ask context clean/i.test( + routeHonestySpec, + ) && /data-event-log-tag/i.test(routeHonestySpec) && + /anchorPreview:\s*publicText/i.test(routeHonestySpec) && + /serializedAnswers[\s\S]{0,120}\.not\.toContain\("Sarah Kim"\)/i.test(routeHonestySpec) && + /serializedAnswers[\s\S]{0,120}\.not\.toContain\("MedLayer"\)/i.test(routeHonestySpec) && /privateSendCalls/i.test(routeHonestySpec), name: "event-log route spec covers tag visibility boundaries", plane: "event-log-evidence", diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index b36e04c0..b517686a 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -619,6 +619,144 @@ test.describe("ScratchNode live route honesty", () => { expect(state.privateSendCalls).toEqual([]); }); + test("private notes anchored from people and company tags keep public ask context clean", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const publicText = "@Alex Chen says #Orbital and #VoiceLayer need a founder follow-up"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicText); + + const publicRow = page.locator(".row", { hasText: "founder follow-up" }).first(); + await expect(publicRow.locator('.mention[data-member="Alex Chen"]')).toHaveText("@Alex Chen"); + await expect(publicRow.locator('.hashtag[data-event-log-tag="orbital"]')).toHaveText( + "#Orbital", + ); + await expect(publicRow.locator('.hashtag[data-event-log-tag="voicelayer"]')).toHaveText( + "#VoiceLayer", + ); + const messageId = await publicRow.getAttribute("data-mid"); + expect(messageId).toMatch(/^liveEventMessages:/); + + await page.evaluate((mid) => { + (window as any).noteOnMessage?.(mid); + }, messageId); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "true"); + await expect(page.locator("#reply-ctx-quote")).toContainText("@Alex Chen says #Orbital"); + + const privateText = + "@Sarah Kim #MedLayer private diligence follow-up: ask for procurement owner"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "false"); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator('.row .mention[data-member="Sarah Kim"]')).toHaveCount(0); + await expect(page.locator('.row .hashtag[data-event-log-tag="medlayer"]')).toHaveCount(0); + await expect(page.locator(".ans", { hasText: privateText })).toHaveCount(0); + await expect(publicRow.locator(".private-note-marker")).toHaveAttribute( + "aria-label", + "1 private note anchored here", + ); + + await page.locator("#lock").click(); + await expect(page.locator("body")).toHaveAttribute("data-mode", "public"); + + const publicPrompt = "what public founder follow-ups mention Orbital and VoiceLayer?"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicPrompt); + + const answerCard = page.locator(".ans").filter({ hasText: publicPrompt }).first(); + await expect(answerCard).toContainText("Mock sourced answer for " + publicPrompt); + await expect(answerCard).toContainText("private notes excluded"); + await expect(answerCard).not.toContainText(privateText); + await expect(answerCard).not.toContainText("Sarah Kim"); + await expect(answerCard).not.toContainText("MedLayer"); + + const anchorState = await page.evaluate( + ({ privateText, publicText, publicPrompt, messageId }) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(privateText), + ); + return { + note, + actions: win.__snMockActions || [], + serializedAnswers: JSON.stringify(win.__snMockAnswers || []), + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && call.args?.text === privateText, + ), + publicAskCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === publicPrompt && + call.args?.kind === "ask", + ), + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === publicText && + call.args?.kind === "chat", + ), + markerCount: document.querySelectorAll( + `.row[data-mid="${messageId}"] .private-note-marker`, + ).length, + }; + }, + { privateText, publicText, publicPrompt, messageId }, + ); + + expect(anchorState.note).toEqual( + expect.objectContaining({ + anchorType: "message", + anchorId: messageId, + anchorPreview: publicText, + }), + ); + expect(anchorState.actions).toEqual([ + expect.objectContaining({ + name: "events:askAgent", + args: expect.objectContaining({ + question: publicPrompt, + }), + }), + ]); + expect(anchorState.serializedAnswers).not.toContain(privateText); + expect(anchorState.serializedAnswers).not.toContain("Sarah Kim"); + expect(anchorState.serializedAnswers).not.toContain("MedLayer"); + expect(anchorState.privateSendCalls).toEqual([]); + expect(anchorState.publicAskCalls).toHaveLength(1); + expect(anchorState.publicSendCalls).toHaveLength(1); + expect(anchorState.markerCount).toBe(1); + }); + test("locked composer saves a private note without public chat or agent calls", async ({ page }) => { await fulfillScratchNodePage(page); From 4b191b6a9a57de8ebb98848dcc5470a21cc11a6c Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 10:06:02 -0700 Subject: [PATCH 82/91] feat: deepen live assist followups --- AGENT_COORDINATION.md | 2 ++ public/proto/home-v5.html | 4 +++- scripts/scratchnode/scanLaunch.mjs | 12 ++++++++++++ tests/e2e/scratchnode-live-route-honesty.spec.ts | 6 ++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index 1aaafe9d..5b4b0d08 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -110,6 +110,8 @@ Keep entries short and honest. Newest on top within each section. ## Recently shipped (this ScratchNode session) +- **Codex** - deeper Live Assist follow-up packet (`public/proto/home-v5.html#live-assist-followup`, `tests/e2e/scratchnode-live-route-honesty.spec.ts#follow-up-depth`, `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): private follow-up notes now include a NodeBench research packet for people, companies, topics, anchors, source refs, and open questions plus an owner-scoped research-task boundary; route test and launch scanner require it without public chat or public `/ask` writes. + - **Codex** - tagged public-row anchor proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#tag-anchor` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves a private note anchored from a public @person/#company event-log row preserves the full public anchor preview, renders only the owner-visible marker, and keeps private person/company follow-up text out of public rows, public `/ask`, and serialized answers; launch scanner now requires the proof before passing. - **Codex** - manual location spot anchor proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#manual-location-spots` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves a private note anchored from a public Booth 12 location moment preserves context, stays out of public chat and public `/ask`, and renders only the owner-visible marker; launch scanner now requires the proof before passing. diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 8e262b58..c96c50ab 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -6958,7 +6958,9 @@

Keyboard shortcuts

'', 'Why it matters: Deepen this after the event in NodeBench with the public wiki plus your private notes.', 'Next step: Ask for the concrete decision, metric, owner, or source behind this cue.', - 'Evidence to capture: quote, speaker/company, promised artifact, and deadline.' + 'Evidence to capture: quote, speaker/company, promised artifact, and deadline.', + 'NodeBench packet: people, companies, topics, anchors, source refs, and open questions.', + 'Deeper follow-up: turn this cue into one owner-scoped research task, not a public room answer.' ]; if (topic && topic.text) lines.push('Event topic: ' + topic.text + (topic.meta ? ' - ' + topic.meta : '')); if (context.length) lines.push('Context: ' + context.join(', ')); diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 08f90c0c..8186fe3f 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -822,6 +822,12 @@ function scanPublicRepoReadiness() { /Cue source: '\s*\+\s*source/i.test(homeHtml) && /Cue skill: '\s*\+\s*skill/i.test(homeHtml) && /Cue trace: '\s*\+\s*traceRef/i.test(homeHtml) && + /NodeBench packet: people, companies, topics, anchors, source refs, and open questions\./i.test( + homeHtml, + ) && + /Deeper follow-up: turn this cue into one owner-scoped research task, not a public room answer\./i.test( + homeHtml, + ) && /Visibility: private follow-up note; not public chat or public \/ask\./i.test(homeHtml) && /Live Assist save cue writes an actual private note without public writes/i.test( routeHonestySpec, @@ -845,6 +851,12 @@ function scanPublicRepoReadiness() { /Cue source: route-test/i.test(routeHonestySpec) && /Cue skill: follow-up-depth/i.test(routeHonestySpec) && /Cue trace: trace_\$\{cueId\}/i.test(routeHonestySpec) && + /NodeBench packet: people, companies, topics, anchors, source refs, and open questions\./i.test( + routeHonestySpec, + ) && + /Deeper follow-up: turn this cue into one owner-scoped research task, not a public room answer\./i.test( + routeHonestySpec, + ) && /publicSendCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), name: "event-log route spec covers structured private follow-up cues", plane: "event-log-evidence", diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index b517686a..b1134c94 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -1479,6 +1479,12 @@ test.describe("ScratchNode live route honesty", () => { expect(state.noteText).toContain("Why it matters: Deepen this after the event in NodeBench"); expect(state.noteText).toContain("Next step: Ask for the concrete decision"); expect(state.noteText).toContain("Evidence to capture: quote, speaker/company"); + expect(state.noteText).toContain( + "NodeBench packet: people, companies, topics, anchors, source refs, and open questions.", + ); + expect(state.noteText).toContain( + "Deeper follow-up: turn this cue into one owner-scoped research task, not a public room answer.", + ); expect(state.noteText).toContain("Event topic: MCP auth - Panel Room A"); expect(state.noteText).toContain("Context: @Orbital Labs, @Alex Chen, [[tenant RBAC]]"); expect(state.noteText).toContain("Cue source: route-test"); From 8d47830cf9e3ad6e2f0ea77006006cdcff5bbce2 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 10:19:26 -0700 Subject: [PATCH 83/91] test: guard attendee join boundary --- AGENT_COORDINATION.md | 2 + scripts/scratchnode/scanLaunch.mjs | 15 ++++++ .../scratchnode-live-route-honesty.spec.ts | 53 +++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index 5b4b0d08..2b54f29b 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -110,6 +110,8 @@ Keep entries short and honest. Newest on top within each section. ## Recently shipped (this ScratchNode session) +- **Codex** - attendee join no-LLM route proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#join-boundary` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves entering a room calls `events:joinEvent` as a membership/check-in event without public chat rows, agent answers, private notes, wiki publication, or `/ask` actions; launch scanner now requires the proof before passing. + - **Codex** - deeper Live Assist follow-up packet (`public/proto/home-v5.html#live-assist-followup`, `tests/e2e/scratchnode-live-route-honesty.spec.ts#follow-up-depth`, `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): private follow-up notes now include a NodeBench research packet for people, companies, topics, anchors, source refs, and open questions plus an owner-scoped research-task boundary; route test and launch scanner require it without public chat or public `/ask` writes. - **Codex** - tagged public-row anchor proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#tag-anchor` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves a private note anchored from a public @person/#company event-log row preserves the full public anchor preview, renders only the owner-visible marker, and keeps private person/company follow-up text out of public rows, public `/ask`, and serialized answers; launch scanner now requires the proof before passing. diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 8186fe3f..14084240 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -761,6 +761,21 @@ function scanPublicRepoReadiness() { plane: "event-log-evidence", detail: files.routeHonestySpec, }); + addCheck({ + ok: + /attendee room join stays a no-LLM membership event without public projections/i.test( + routeHonestySpec, + ) && + /events:joinEvent/i.test(routeHonestySpec) && + /expect\(joinState\.messageCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(joinState\.noteCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(joinState\.wikiCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(joinState\.askActions\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(joinState\.publishedWiki\)\.toBeNull\(\)/i.test(routeHonestySpec), + name: "event-log route spec covers attendee join no-LLM boundary", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); addCheck({ ok: /normal public replies stay chat-only event-log moments/i.test(routeHonestySpec) && diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index b1134c94..939dd8bf 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -399,6 +399,59 @@ test.describe("ScratchNode live route honesty", () => { await expect(page.locator("#ci")).toHaveValue("this must not be local-only"); }); + test("attendee room join stays a no-LLM membership event without public projections", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + await expect(page.locator(".row, .ans")).toHaveCount(0); + + const joinState = await page.evaluate(() => { + const win = window as any; + return { + joinCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:joinEvent", + ), + messageCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage", + ), + noteCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "notes:createNote", + ), + wikiCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:publishWiki", + ), + askActions: (win.__snMockActions || []).filter( + (call: any) => call.name === "events:askAgent", + ), + messages: win.__snMockMessages || [], + answers: win.__snMockAnswers || [], + notes: win.__snMockNotes || [], + publishedWiki: win.__snMockPublishedWiki, + }; + }); + + expect(joinState.joinCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + slug: "orbital", + displayName: expect.any(String), + sessionId: expect.any(String), + }), + }), + ]); + expect(joinState.messageCalls).toEqual([]); + expect(joinState.noteCalls).toEqual([]); + expect(joinState.wikiCalls).toEqual([]); + expect(joinState.askActions).toEqual([]); + expect(joinState.messages).toEqual([]); + expect(joinState.answers).toEqual([]); + expect(joinState.notes).toEqual([]); + expect(joinState.publishedWiki).toBeNull(); + }); + test("/ask answer Share copies a REAL link (no fake 'Shared' toast)", async ({ page }) => { expect(HOME_V5_HTML).not.toContain("toast('Shared'"); expect(HOME_V5_HTML).toContain("function _snShareAnswer"); From 5ec9e8e0aed4885b0a79883fd6390b7dbf874243 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 10:32:30 -0700 Subject: [PATCH 84/91] Detect manual event location captures --- .../product/lib/captureRouter.test.ts | 24 +++++++++++++++++++ src/features/product/lib/captureRouter.ts | 10 ++++---- .../lib/eventWorkspacePersistence.test.ts | 22 +++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/features/product/lib/captureRouter.test.ts b/src/features/product/lib/captureRouter.test.ts index 7300a719..38181626 100644 --- a/src/features/product/lib/captureRouter.test.ts +++ b/src/features/product/lib/captureRouter.test.ts @@ -35,6 +35,30 @@ describe("inferCaptureRoute", () => { ); }); + it("routes manual location-spot notes into the active event session without extra event keywords", () => { + const route = inferCaptureRoute({ + text: "Investor Lounge follow-up: ask Priya for the sponsor list after the panel.", + mode: "note", + }); + + expect(route.intent).toBe("create_followup"); + expect(route.target).toBe("active_event_session"); + expect(route.gate).toBe("auto_route"); + expect(route.followUps.some((item) => item.text.includes("Investor Lounge follow-up"))).toBe(true); + expect(route.ack).toContain("Saved to active event session"); + }); + + it("treats afterparty location notes as event captures instead of unassigned notes", () => { + const route = inferCaptureRoute({ + text: "Afterparty notes: founder intros moved to the rooftop bar.", + mode: "note", + }); + + expect(route.intent).toBe("capture_field_note"); + expect(route.target).toBe("active_event_session"); + expect(route.needsConfirmation).toBe(false); + }); + it("keeps uncertain low-signal captures in review", () => { const route = inferCaptureRoute({ text: "interesting thing from last week", diff --git a/src/features/product/lib/captureRouter.ts b/src/features/product/lib/captureRouter.ts index 2be272ad..5be572e1 100644 --- a/src/features/product/lib/captureRouter.ts +++ b/src/features/product/lib/captureRouter.ts @@ -78,6 +78,7 @@ const FIELD_NOTE_MARKERS = /\b(met|talked|spoke|coffee|demo day|conference|booth const FOLLOW_UP_MARKERS = /\b(follow up|follow-up|todo|remind|task|next step|ask them|email|intro|reply|schedule)\b/i; const APPEND_MARKERS = /\b(add|attach|save|append|put this|log this)\b.*\b(report|brief|dossier|workspace|notebook)\b/i; const EVENT_MARKERS = /\b(demo day|conference|event|booth|lecture|whiteboard|pitch|summit|meetup)\b/i; +const LOCATION_SPOT_MARKERS = /\b(booth\s*\d+|lobby|panel\s+room\s+[a-z0-9]+|investor\s+lounge|afterparty)\b/i; const INBOX_MARKERS = /\b(recruiter|email|inbox|newsletter|invite|application|offer|rejected|job spec)\b/i; const REPORT_MARKERS = /\b(report|brief|dossier|market map|prd|memo|company|startup|competitor|vendor|paper|repo)\b/i; const COMPANY_SUFFIX = /\b(Inc|Labs|AI|Systems|Technologies|Tech|Health|Bio|Robotics|Capital|Ventures|Partners|Bank|University|Labs)\b/; @@ -168,7 +169,7 @@ function inferIntent( if (mode === "ask" && EXPLORE_REQUEST_MARKERS.test(text) && (EVENT_MARKERS.test(text) || REPORT_MARKERS.test(text))) { return "expand_entity"; } - if (FIELD_NOTE_MARKERS.test(text) || hasFiles) return "capture_field_note"; + if (FIELD_NOTE_MARKERS.test(text) || LOCATION_SPOT_MARKERS.test(text) || hasFiles) return "capture_field_note"; if (QUESTION_START.test(text) || text.includes("?")) { return REPORT_MARKERS.test(text) ? "expand_entity" : "ask_question"; } @@ -183,11 +184,11 @@ function inferTarget( ): CaptureTarget { const context = activeContextLabel?.trim() ?? ""; const looksLikeEventContext = - EVENT_MARKERS.test(context) || /\b(demo|conference|event|summit|meetup)\b/i.test(context); + EVENT_MARKERS.test(context) || LOCATION_SPOT_MARKERS.test(context) || /\b(demo|conference|event|summit|meetup)\b/i.test(context); const looksLikeEventCapture = - /\b(?:met|talked to|spoke with)\s+[A-Z][A-Za-z.-]*(?:\s+[A-Z][A-Za-z.-]*)?\s+from\s+[A-Z]/i.test(text); + /\b(?:met|talked to|spoke with)\s+[A-Z][A-Za-z.-]*(?:\s+(?!from\b)[A-Z][A-Za-z.-]*)?\s+from\s+[A-Z]/i.test(text); - if (EVENT_MARKERS.test(text) || looksLikeEventContext || looksLikeEventCapture) { + if (EVENT_MARKERS.test(text) || LOCATION_SPOT_MARKERS.test(text) || looksLikeEventContext || looksLikeEventCapture) { return "active_event_session"; } if (INBOX_MARKERS.test(text)) return "inbox_item"; @@ -216,6 +217,7 @@ function scoreRoute(args: { if (args.followUps.length > 0) score += 0.08; if (args.target !== "unassigned_buffer") score += 0.08; if (args.target === "active_event_session" && args.entities.length >= 2) score += 0.04; + if (args.target === "active_event_session" && LOCATION_SPOT_MARKERS.test(args.text)) score += 0.32; if (args.intent === "ask_question" || args.intent === "expand_entity") score += 0.04; if (args.text.length > 240) score += 0.04; return Math.min(0.96, Number(score.toFixed(2))); diff --git a/src/features/workspace/lib/eventWorkspacePersistence.test.ts b/src/features/workspace/lib/eventWorkspacePersistence.test.ts index 08664a37..78244a8f 100644 --- a/src/features/workspace/lib/eventWorkspacePersistence.test.ts +++ b/src/features/workspace/lib/eventWorkspacePersistence.test.ts @@ -80,6 +80,28 @@ describe("eventWorkspacePersistence", () => { expect(args.capture.extractedClaimIds.length).toBe(args.claims.length); }); + it("persists manual location-spot captures into the event workspace follow-up lane", () => { + const input = "Investor Lounge follow-up: ask Priya for the sponsor list after the panel."; + const route = inferCaptureRoute({ + text: input, + mode: "note", + }); + const args = buildLiveCaptureArgs({ + workspaceId: "ai-infra-summit-2026", + input, + now: 1777068715905, + route, + }); + + expect(shouldPersistRouteToEventWorkspace(route)).toBe(true); + expect(args.capture.status).toBe("attached"); + expect(args.followUps.some((followUp) => followUp.action.includes("Investor Lounge follow-up"))).toBe(true); + expect(args.evidence[0]).toMatchObject({ + layer: "private_capture", + visibility: "private", + }); + }); + it("maps live Convex rows back into the workspace memory view model", () => { const mapped = mapLiveSnapshotToMemory({ entities: [ From cae49d74e2d380c17cb23c369a42f97fb91e154b Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 10:44:43 -0700 Subject: [PATCH 85/91] test: guard location anchor export --- docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md | 2 +- scripts/repo/__tests__/scratchnodePublicExport.test.ts | 1 + scripts/repo/export-scratchnode-live-public.mjs | 3 ++- scripts/scratchnode/scanLaunch.mjs | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md b/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md index 4a36c819..ea7b0652 100644 --- a/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md +++ b/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md @@ -145,7 +145,7 @@ The script: Exported `contracts/scratchnode-live-api.json` must keep two projections explicit: - `publicEventLogJson`: public event metadata, public chat, public `/ask` answers, host-promoted wiki sections, public sources, and typed manual location spots. It excludes private notes, owner keys, session ids, handoff tokens, and NodeBench workspace artifacts. -- `ownerPrivateNoteProjection`: owner-only private notes, anchors, follow-ups, voice transcripts, and NodeBench handoff context. It excludes public wiki JSON, public `/ask` cache, public answer traces, and other attendees' notes. +- `ownerPrivateNoteProjection`: owner-only private notes, anchors, manual location spot anchors, follow-ups, voice transcripts, and NodeBench handoff context. It excludes public wiki JSON, public `/ask` cache, public answer traces, and other attendees' notes. ## Backend Compatibility Rule diff --git a/scripts/repo/__tests__/scratchnodePublicExport.test.ts b/scripts/repo/__tests__/scratchnodePublicExport.test.ts index 0744e06d..ba656fb0 100644 --- a/scripts/repo/__tests__/scratchnodePublicExport.test.ts +++ b/scripts/repo/__tests__/scratchnodePublicExport.test.ts @@ -77,6 +77,7 @@ describe("scratchnode public export", () => { includes: expect.arrayContaining([ "owner private notes", "private note anchors", + "manual location spot anchors", "private follow-ups", "NodeBench handoff context", ]), diff --git a/scripts/repo/export-scratchnode-live-public.mjs b/scripts/repo/export-scratchnode-live-public.mjs index 693d3a6c..5018a0c8 100644 --- a/scripts/repo/export-scratchnode-live-public.mjs +++ b/scripts/repo/export-scratchnode-live-public.mjs @@ -439,6 +439,7 @@ function buildApiContract() { includes: [ "owner private notes", "private note anchors", + "manual location spot anchors", "private follow-ups", "owner voice transcripts", "NodeBench handoff context", @@ -513,7 +514,7 @@ if (publicLog.visibility !== "public" || missingPublicExclusions.length) { console.error("Public event-log projection contract is incomplete."); process.exit(1); } -const requiredPrivateIncludes = ["owner private notes", "private note anchors", "private follow-ups", "owner voice transcripts", "NodeBench handoff context"]; +const requiredPrivateIncludes = ["owner private notes", "private note anchors", "manual location spot anchors", "private follow-ups", "owner voice transcripts", "NodeBench handoff context"]; const requiredPrivateExclusions = ["public wiki JSON", "public /ask cache", "public answer traces", "other attendees' notes"]; const missingPrivateIncludes = requiredPrivateIncludes.filter((entry) => !(privateProjection.includes || []).includes(entry)); const missingPrivateExclusions = requiredPrivateExclusions.filter((entry) => !(privateProjection.excludes || []).includes(entry)); diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 14084240..debcc818 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -720,6 +720,7 @@ function scanPublicRepoReadiness() { /requiredPrivateExclusions/i.test(exportScript) && /owner private notes/i.test(exportScript) && /private note anchors/i.test(exportScript) && + /manual location spot anchors/i.test(exportScript) && /private follow-ups/i.test(exportScript) && /owner voice transcripts/i.test(exportScript) && /NodeBench handoff context/i.test(exportScript) && From 87a9c39299d5f70071ea46c3b8272030a18d374b Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 11:05:21 -0700 Subject: [PATCH 86/91] feat: deepen event followup routing --- .../product/lib/captureRouter.test.ts | 22 +++++++++++++++++++ src/features/product/lib/captureRouter.ts | 17 ++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/features/product/lib/captureRouter.test.ts b/src/features/product/lib/captureRouter.test.ts index 38181626..7012fe61 100644 --- a/src/features/product/lib/captureRouter.test.ts +++ b/src/features/product/lib/captureRouter.test.ts @@ -48,6 +48,28 @@ describe("inferCaptureRoute", () => { expect(route.ack).toContain("Saved to active event session"); }); + it("routes deeper self-directed event follow-ups with location anchors", () => { + const route = inferCaptureRoute({ + text: "Go deeper on Priya from Helio Labs at Panel Room A and prep next questions for the sponsor intro.", + mode: "task", + }); + + expect(route.intent).toBe("create_followup"); + expect(route.target).toBe("active_event_session"); + expect(route.gate).toBe("auto_route"); + expect(route.entities.map((entity) => entity.name)).toEqual( + expect.arrayContaining(["Priya", "Helio Labs"]), + ); + expect(route.followUps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: "Deepen event follow-up across people, companies, topics, and anchors", + priority: "high", + }), + ]), + ); + }); + it("treats afterparty location notes as event captures instead of unassigned notes", () => { const route = inferCaptureRoute({ text: "Afterparty notes: founder intros moved to the rooftop bar.", diff --git a/src/features/product/lib/captureRouter.ts b/src/features/product/lib/captureRouter.ts index 5be572e1..eb1b8769 100644 --- a/src/features/product/lib/captureRouter.ts +++ b/src/features/product/lib/captureRouter.ts @@ -76,6 +76,7 @@ const QUESTION_START = /^(ask|who|what|when|where|why|how|which|compare|research const EXPLORE_REQUEST_MARKERS = /\b(need|create|build|produce|turn this into|cluster|compare|rank|verify|diligence|research|explore|workspace|map|brief|sources|cards|notebook)\b/i; const FIELD_NOTE_MARKERS = /\b(met|talked|spoke|coffee|demo day|conference|booth|voice memo|recorded|handwritten|whiteboard|screenshot|photo|notes?|lecture|pitch|customer call)\b/i; const FOLLOW_UP_MARKERS = /\b(follow up|follow-up|todo|remind|task|next step|ask them|email|intro|reply|schedule)\b/i; +const DEEPER_FOLLOW_UP_MARKERS = /\b(go deeper|deep dive|deeper follow-up|deeper follow up|deepen|research next|follow through)\b/i; const APPEND_MARKERS = /\b(add|attach|save|append|put this|log this)\b.*\b(report|brief|dossier|workspace|notebook)\b/i; const EVENT_MARKERS = /\b(demo day|conference|event|booth|lecture|whiteboard|pitch|summit|meetup)\b/i; const LOCATION_SPOT_MARKERS = /\b(booth\s*\d+|lobby|panel\s+room\s+[a-z0-9]+|investor\s+lounge|afterparty)\b/i; @@ -162,7 +163,7 @@ function inferIntent( hasFiles: boolean, ): CaptureIntent { if (mode === "task") return "create_followup"; - if (FOLLOW_UP_MARKERS.test(text)) return "create_followup"; + if (FOLLOW_UP_MARKERS.test(text) || DEEPER_FOLLOW_UP_MARKERS.test(text)) return "create_followup"; if (APPEND_MARKERS.test(text)) return "append_to_report"; if (mode === "note") return "capture_field_note"; if (/^\s*ask\b/i.test(text)) return "ask_question"; @@ -255,6 +256,11 @@ function extractEntities( const metMatch = text.match(/\b(?:met|talked to|spoke with|coffee with)\s+([A-Z][A-Za-z.-]*(?:\s+(?!from\b)[A-Z][A-Za-z.-]*)?)/i); if (metMatch) add(metMatch[1], "person", 0.82); + const deeperFollowUpPersonMatch = text.match( + /\b(?:go deeper on|deep dive on|deepen|research next|follow through with)\s+([A-Z][A-Za-z.-]*(?:\s+(?!from\b|at\b|with\b|and\b)[A-Z][A-Za-z.-]*)?)/i, + ); + if (deeperFollowUpPersonMatch) add(deeperFollowUpPersonMatch[1], "person", 0.78); + const capitalized = text.match(/\b[A-Z][A-Za-z0-9&-]*(?:\s+[A-Z][A-Za-z0-9&-]*){0,3}\b/g) ?? []; for (const phrase of capitalized) { const first = phrase.split(/\s+/)[0]; @@ -312,12 +318,19 @@ function extractFollowUps( ): CaptureFollowUp[] { const followUps: CaptureFollowUp[] = []; const normalized = text.toLowerCase(); - if (FOLLOW_UP_MARKERS.test(text) || intent === "create_followup") { + const wantsDeeperFollowUp = DEEPER_FOLLOW_UP_MARKERS.test(text); + if (FOLLOW_UP_MARKERS.test(text) || wantsDeeperFollowUp || intent === "create_followup") { followUps.push({ text: text.replace(/^[-*]\s*/, "").slice(0, 160), priority: normalized.includes("urgent") || normalized.includes("tomorrow") ? "high" : "medium", }); } + if (wantsDeeperFollowUp && target === "active_event_session") { + followUps.push({ + text: "Deepen event follow-up across people, companies, topics, and anchors", + priority: "high", + }); + } if (normalized.includes("design partner") || normalized.includes("pilot")) { followUps.push({ text: "Ask about pilot criteria and design-partner timeline", From 4253a9a0feafe9b13d27f7652dd41f1f7ed48b99 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 11:29:28 -0700 Subject: [PATCH 87/91] test: guard wiki reader ownership --- api/scratchnode-wiki.js | 6 +++++ scripts/scratchnode/scanLaunch.mjs | 22 +++++++++++++++++++ .../events/views/ScratchnodeWikiBridge.tsx | 5 +++++ tests/e2e/scratchnode-public-wiki.spec.ts | 4 ++-- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/api/scratchnode-wiki.js b/api/scratchnode-wiki.js index 39407c7e..8f78cf3b 100644 --- a/api/scratchnode-wiki.js +++ b/api/scratchnode-wiki.js @@ -1,6 +1,12 @@ import { ConvexHttpClient } from "convex/browser"; import { api } from "../convex/_generated/api.js"; +// Ownership: this is the ScratchNode-owned public wiki SSR route for +// scratchnode.live. It serves public, host-published wiki HTML/JSON from Convex +// and must not mint or accept NodeBench private handoff tokens. +// The NodeBench-owned receiver is ScratchnodeWikiBridge at +// nodebenchai.com/events/:slug/wiki. + let convexClient = null; function getConvexClient() { diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index debcc818..54636677 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -16,6 +16,7 @@ const files = { docsHtml: "public/proto/docs.html", vercel: "vercel.json", scratchnodeConfig: "api/scratchnode-config.js", + scratchnodeWikiServerless: "api/scratchnode-wiki.js", events: "convex/events.ts", notes: "convex/notes.ts", users: "convex/users.ts", @@ -31,6 +32,7 @@ const files = { runtimeGoal: "goals/runtime/001-public-private-boundary.md", eventsRuntimeBoundarySpec: "convex/events.runtime-boundary.test.ts", routeHonestySpec: "tests/e2e/scratchnode-live-route-honesty.spec.ts", + scratchnodePublicWikiSpec: "tests/e2e/scratchnode-public-wiki.spec.ts", scratchnodeWikiBridgeSpec: "src/features/events/views/ScratchnodeWikiBridge.test.tsx", demoQa: "qa/run_demo_full.md", readme: "README.md", @@ -673,7 +675,9 @@ function scanPublicRepoReadiness() { files.scratchnodeEventLogGoal, files.nodebenchGoal, files.runtimeGoal, + files.scratchnodeWikiServerless, files.routeHonestySpec, + files.scratchnodePublicWikiSpec, files.scratchnodeWikiBridgeSpec, files.demoQa, files.license, @@ -688,6 +692,9 @@ function scanPublicRepoReadiness() { const readme = readText(files.readme); const homeHtml = readText(files.homeV5); const routeHonestySpec = readText(files.routeHonestySpec); + const scratchnodeWikiServerless = readText(files.scratchnodeWikiServerless); + const scratchnodePublicWikiSpec = readText(files.scratchnodePublicWikiSpec); + const scratchnodeWikiBridge = readText("src/features/events/views/ScratchnodeWikiBridge.tsx"); const scratchnodeWikiBridgeSpec = readText(files.scratchnodeWikiBridgeSpec); addCheck({ @@ -989,6 +996,21 @@ function scanPublicRepoReadiness() { plane: "event-log-evidence", detail: files.scratchnodeWikiBridgeSpec, }); + addCheck({ + ok: + /ScratchNode-owned public wiki SSR route/i.test(scratchnodeWikiServerless) && + /must not mint or accept NodeBench private handoff tokens/i.test(scratchnodeWikiServerless) && + /NodeBench-owned receiver/i.test(scratchnodeWikiServerless) && + /api\.events\.getPublishedWikiBySlug/i.test(scratchnodeWikiServerless) && + /Private notes are excluded/i.test(scratchnodeWikiServerless) && + /NodeBench-owned bridge\/conversion surface/i.test(scratchnodeWikiBridge) && + /not the[\s\S]{0,120}ScratchNode-owned public wiki SSR reader/i.test(scratchnodeWikiBridge) && + /must not duplicate[\s\S]{0,120}ScratchNode publishing\/SSR ownership/i.test(scratchnodeWikiBridge) && + /no "Continue in NodeBench" CTA here/i.test(scratchnodePublicWikiSpec), + name: "ScratchNode and NodeBench wiki readers have explicit ownership split", + plane: "event-log-evidence", + detail: `${files.scratchnodeWikiServerless} + src/features/events/views/ScratchnodeWikiBridge.tsx`, + }); } function scanGoalAutomationReadiness() { diff --git a/src/features/events/views/ScratchnodeWikiBridge.tsx b/src/features/events/views/ScratchnodeWikiBridge.tsx index 203fdafc..ce7e6ac4 100644 --- a/src/features/events/views/ScratchnodeWikiBridge.tsx +++ b/src/features/events/views/ScratchnodeWikiBridge.tsx @@ -20,6 +20,11 @@ * * Prior art: pattern mirrors src/features/redesign/surfaces/ScratchnodeEventsSurface.tsx * (the existing read-only ScratchNode handoff surface). + * + * Ownership: this is the NodeBench-owned bridge/conversion surface, not the + * ScratchNode-owned public wiki SSR reader in `api/scratchnode-wiki.js`. The + * two may read the same public wiki query, but this route must not duplicate + * ScratchNode publishing/SSR ownership or accept private tokens. */ import { useMemo } from "react"; diff --git a/tests/e2e/scratchnode-public-wiki.spec.ts b/tests/e2e/scratchnode-public-wiki.spec.ts index bc36e172..51b31e52 100644 --- a/tests/e2e/scratchnode-public-wiki.spec.ts +++ b/tests/e2e/scratchnode-public-wiki.spec.ts @@ -94,8 +94,8 @@ test.describe("ScratchNode public /wiki/ reader", () => { const createCta = page.locator('.sn-wiki__foot a.sn-wiki__cta', { hasText: "Create your own room" }); await expect(createCta).toHaveAttribute("href", "/"); - // HONESTY: no "Continue in NodeBench" CTA here — its receiving route doesn't - // exist yet (it would 404), so it ships WITH that route, not before it. + // HONESTY: no "Continue in NodeBench" CTA here until the queued ScratchNode + // public reader change targets the NodeBench bridge with visibility-safe params. await expect(page.locator("#sn-wiki-nb")).toHaveCount(0); // The room shell must be hidden in wiki mode. From e6ef009736721410cf0d695b4348e90512fad395 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 11:50:00 -0700 Subject: [PATCH 88/91] test: cover spreadsheet row delta audit path --- .../spreadsheets.applyRowDelta.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 convex/__tests__/spreadsheets.applyRowDelta.test.ts diff --git a/convex/__tests__/spreadsheets.applyRowDelta.test.ts b/convex/__tests__/spreadsheets.applyRowDelta.test.ts new file mode 100644 index 00000000..8468e540 --- /dev/null +++ b/convex/__tests__/spreadsheets.applyRowDelta.test.ts @@ -0,0 +1,76 @@ +/// +import { describe, expect, it } from "vitest"; +import { api } from "../_generated/api"; +import schema from "../schema"; + +const convexModules = import.meta.glob("../**/*.{ts,js}"); + +let convexTest: any; +let convexTestAvailable = false; +try { + const mod = await import(/* @vite-ignore */ "convex-test"); + convexTest = mod.convexTest; + convexTestAvailable = typeof convexTest === "function"; +} catch { + convexTestAvailable = false; +} + +describe.skipIf(!convexTestAvailable)("spreadsheets.applyRowDelta", () => { + it("applies an ordered row delta, stores explicit blanks, and writes an audit event", async () => { + const t = convexTest(schema, convexModules); + const userId = await t.run(async (ctx: any) => + ctx.db.insert("users", { + name: "Spreadsheet Smoke User", + }), + ); + const authed = t.withIdentity({ subject: userId }); + + const sheetId = await authed.mutation(api.domains.integrations.spreadsheets.createSheet, { + name: "row-delta-smoke", + }); + const sheet = await authed.query(api.domains.integrations.spreadsheets.getSheet, { sheetId }); + + const result = await authed.mutation(api.domains.integrations.spreadsheets.applyRowDelta, { + sheetId, + row: 0, + expectedUpdatedAt: sheet.updatedAt, + source: "vitest-row-delta", + threadId: "thread-row-delta", + runId: "run-row-delta", + operations: [ + { op: "insert", index: 0, value: "Revenue" }, + { op: "insert", index: 1, value: 42 }, + { op: "insert", index: 1, value: null }, + { op: "set", index: 2, value: 43 }, + ], + }); + + expect(result.before).toEqual([]); + expect(result.after).toEqual(["Revenue", null, 43]); + expect(result.applied).toBe(4); + expect(result.errors).toBe(0); + + const cells = await authed.query(api.domains.integrations.spreadsheets.getRange, { + sheetId, + startRow: 0, + endRow: 0, + startCol: 0, + endCol: 2, + }); + expect(cells.map((cell: any) => ({ col: cell.col, value: cell.value, type: cell.type }))).toEqual([ + { col: 0, value: "Revenue", type: "text" }, + { col: 1, value: "", type: "blank" }, + { col: 2, value: "43", type: "number" }, + ]); + + const events = await t.run(async (ctx: any) => + ctx.db + .query("spreadsheetEvents") + .withIndex("by_spreadsheet", (q: any) => q.eq("spreadsheetId", sheetId)) + .collect(), + ); + expect(events).toHaveLength(1); + expect(events[0].operation).toBe("row_delta"); + expect(events[0].payload.after).toEqual(["Revenue", null, 43]); + }); +}); From eebd055c3ed4437771c39a0eb49f7b7632a5e79c Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 11:53:13 -0700 Subject: [PATCH 89/91] feat: link public wiki to NodeBench --- public/proto/home-v5.html | 22 ++++++++++++++++++---- scripts/scratchnode/scanLaunch.mjs | 13 ++++++++++++- tests/e2e/scratchnode-public-wiki.spec.ts | 14 +++++++++++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index c96c50ab..260e8e46 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -3122,6 +3122,7 @@ try { document.title = (wiki.eventName || 'Event') + ' — wiki · ScratchNode'; } catch (e) {} var when = ''; try { if (wiki.publishedAt) when = new Date(wiki.publishedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } catch (e) {} + var nodeBenchWikiUrl = buildNodeBenchPublicWikiUrl(wiki); mainEl.innerHTML = '
Event wiki' + (typeof wiki.version === 'number' ? ' · v' + wiki.version : '') + '
' + '

' + @@ -3129,6 +3130,7 @@ '
' + '
' + '
The room remembers everything. This recap was built live with ScratchNode.
' + + 'Continue in NodeBench →' + 'Create your own room →' + '
'; mainEl.querySelector('.sn-wiki__title').textContent = wiki.title || (wiki.eventName ? wiki.eventName + ' — wiki' : 'Event wiki'); @@ -3138,10 +3140,6 @@ (when ? ' · published ' + when : ''); // bodyHtml is server-built + public-safe (private notes excluded at publish time). mainEl.querySelector('#sn-wiki-article').innerHTML = wiki.bodyHtml; - // NOTE: a "Continue in NodeBench" bridge CTA is intentionally NOT here yet — the - // nodebenchai.com/events//wiki receiving route doesn't exist (the /events - // router rejects trailing segments), so a CTA would 404. It ships WITH its real - // receiving route so the link can never dead-end. See PR-D. } function _snWikiShare() { @@ -3902,6 +3900,22 @@

Keyboard shortcuts

return EVENT_URL + '/wiki'; } +function buildNodeBenchPublicWikiUrl(wiki) { + var slug = ''; + var roomCode = ''; + try { + slug = (wiki && (wiki.eventSlug || wiki.slug)) || (window._sn_wiki && window._sn_wiki.slug) || EVENT_SLUG; + roomCode = (wiki && wiki.roomCode) || EVENT_ROOM_CODE; + } catch (e) { + slug = EVENT_SLUG; + roomCode = EVENT_ROOM_CODE; + } + return WORKSPACE_BASE_URL + + '/events/' + encodeURIComponent(slug) + '/wiki' + + '?source=scratchnode' + + '&room=' + encodeURIComponent(roomCode || ''); +} + function copyTextOrToast(text, successTitle, successDetail) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text).then(function() { diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index 54636677..cc3c9435 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -1006,7 +1006,18 @@ function scanPublicRepoReadiness() { /NodeBench-owned bridge\/conversion surface/i.test(scratchnodeWikiBridge) && /not the[\s\S]{0,120}ScratchNode-owned public wiki SSR reader/i.test(scratchnodeWikiBridge) && /must not duplicate[\s\S]{0,120}ScratchNode publishing\/SSR ownership/i.test(scratchnodeWikiBridge) && - /no "Continue in NodeBench" CTA here/i.test(scratchnodePublicWikiSpec), + /function\s+buildNodeBenchPublicWikiUrl\s*\(\s*wiki\s*\)/i.test(homeHtml) && + /id=["']sn-wiki-nb["']/i.test(homeHtml) && + /\/events\/' \+ encodeURIComponent\(slug\) \+ '\/wiki'/i.test(homeHtml) && + /\?source=scratchnode/i.test(homeHtml) && + /https:\/\/nodebenchai\.com\/events\/rooftop-launch\/wiki\?source=scratchnode&room=ROOFTOP/i.test( + scratchnodePublicWikiSpec, + ) && + /expect\(nodeBenchHref\)\.not\.toContain\("token="\)/i.test(scratchnodePublicWikiSpec) && + /expect\(nodeBenchHref\)\.not\.toContain\("session="\)/i.test(scratchnodePublicWikiSpec) && + /expect\(nodeBenchHref\)\.not\.toContain\("continuation="\)/i.test(scratchnodePublicWikiSpec) && + /expect\(nodeBenchHref\)\.not\.toContain\("publicArtifact="\)/i.test(scratchnodePublicWikiSpec) && + /expect\(nodeBenchHref\)\.not\.toContain\("noteCount="\)/i.test(scratchnodePublicWikiSpec), name: "ScratchNode and NodeBench wiki readers have explicit ownership split", plane: "event-log-evidence", detail: `${files.scratchnodeWikiServerless} + src/features/events/views/ScratchnodeWikiBridge.tsx`, diff --git a/tests/e2e/scratchnode-public-wiki.spec.ts b/tests/e2e/scratchnode-public-wiki.spec.ts index 51b31e52..344cb030 100644 --- a/tests/e2e/scratchnode-public-wiki.spec.ts +++ b/tests/e2e/scratchnode-public-wiki.spec.ts @@ -94,9 +94,17 @@ test.describe("ScratchNode public /wiki/ reader", () => { const createCta = page.locator('.sn-wiki__foot a.sn-wiki__cta', { hasText: "Create your own room" }); await expect(createCta).toHaveAttribute("href", "/"); - // HONESTY: no "Continue in NodeBench" CTA here until the queued ScratchNode - // public reader change targets the NodeBench bridge with visibility-safe params. - await expect(page.locator("#sn-wiki-nb")).toHaveCount(0); + const nodeBenchCta = page.locator("#sn-wiki-nb"); + await expect(nodeBenchCta).toHaveAttribute( + "href", + "https://nodebenchai.com/events/rooftop-launch/wiki?source=scratchnode&room=ROOFTOP", + ); + const nodeBenchHref = await nodeBenchCta.getAttribute("href"); + expect(nodeBenchHref).not.toContain("token="); + expect(nodeBenchHref).not.toContain("session="); + expect(nodeBenchHref).not.toContain("continuation="); + expect(nodeBenchHref).not.toContain("publicArtifact="); + expect(nodeBenchHref).not.toContain("noteCount="); // The room shell must be hidden in wiki mode. await expect(page.locator("main.m")).toBeHidden(); From e0e96dc0738d26b3f77ac2712ed87ac664386860 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 12:34:47 -0700 Subject: [PATCH 90/91] =?UTF-8?q?fix(scratchnode-v5):=20mobile=20visual=20?= =?UTF-8?q?reset=20=E2=80=94=20type=20scale,=20accent/mono=20discipline,?= =?UTF-8?q?=20cut=20first-viewport=20chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause was not "too many elements": :root had no type-size tokens (every component hardcoded sizes, so nothing ranked) and no accent/mono discipline (accent sprayed on logo/code/CTA/links; mono on human prose, not just IDs). Plus a flex-gap bug split the "ScratchNode" wordmark into "Scratch Node". - :root: add --fs-display/title/base/sub/label/mono type scale; reserve solid accent for the single primary action - header: fix wordmark flex-gap split; room code -> quiet muted mono chip; borderless menu icon - event strip: drop "0 FAQ"; gate L0 capture + event-mode to data-role=host (host/debug controls that leaked to attendees); de-mono to --ui - hero: drop duplicate joined count; "Disposable event brain" -> "Live event log - public wiki when it ends" (static + JS rewrite) - welcome banner: quiet (no accent card) + hidden on mobile - composer: placeholder -> "Message or /ask..." (fixes clipped placeholder, which came from JS not static markup); helpline 2 lines -> 1; privacy shows "Public" / "Private (lock)" text instead of an ambiguous open-lock glyph - empty state: remove giant "Ask the first question" CTA (composer is the CTA); "No messages yet" is the one 18px display element; copy teaches all 3 actions - keyboard: visualViewport --keyboard-offset pins the fixed composer above the keyboard; footer + welcome collapse while typing (data-input-focused) -> no more footer leaking behind the keyboard - menu: gate "Continue in NodeBench" to named users (was visible to anonymous guests under the hidden "Your notes" header); hide "Keyboard shortcuts" on mobile Presentational + copy only. Send/render path, seenIds dedup, and data-sn-live are untouched. Desktop layout unchanged (composer stays sticky-top). Verified: 49/49 chromium e2e (scratchnode-live-route-honesty 46 incl. home-v5-output-contract, + scratchnode-public-wiki 3); static launch scan PASS (0 blockers/warnings); before/after/keyboard/menu screenshots at 390px. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENT_COORDINATION.md | 2 + CHANGELOG/pages/proto-home-v5.md | 43 +++++++ public/proto/home-v5.html | 188 +++++++++++++++++++++---------- 3 files changed, 172 insertions(+), 61 deletions(-) diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index 2b54f29b..ae754276 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -34,6 +34,8 @@ Keep entries short and honest. Newest on top within each section. ## Active claims (who is editing what RIGHT NOW) +- **Claude · `home-v5.html#mobile-chrome` (`:root` type tokens, `.h`/`.h-logo`/`.h-code`/`.h-menu`, `.event-strip`, `.hero`/`.hero-meta`, `.welcome`, `.c-helpline` + composer placeholder, `.empty*`, `.menu-sheet` item gating, `.f` footer + keyboard-offset CSS/JS) · mobile visual reset: add type scale + accent/mono discipline, cut first-viewport chrome (FAQ/L0/mode leaks, dup hero, giant CTA), keyboard-open footer fix · branch `codex/scratchnode-public-rooms`. NOT touching the Share-sheet/wiki internals (Codex's `#wiki-share`), `convex/`, or `data-sn-live`.** + - _(Released: Codex packaged `codex/scratchnode-public-wiki-loop` for PR review; no active edit lock remains.)_ - **Codex · `home-v5.html#wiki-share`, `convex/events.ts#wiki-public-read`, `vercel.json#scratchnode-wiki-route`, `tests/e2e/scratchnode-live-route-honesty.spec.ts#wiki-route` · build published-only public wiki payoff + honest answer share · branch `codex/scratchnode-public-wiki-loop`.** diff --git a/CHANGELOG/pages/proto-home-v5.md b/CHANGELOG/pages/proto-home-v5.md index eac43bf5..1c5b8f89 100644 --- a/CHANGELOG/pages/proto-home-v5.md +++ b/CHANGELOG/pages/proto-home-v5.md @@ -2,6 +2,49 @@ Append-only lane for the ScratchNode live-event prototype and production static surface. +## 2026-06-03 — Mobile visual reset: type scale + accent/mono discipline, cut first-viewport chrome +The mobile room read as a prototype because two systems were missing, not because of any +single bad component. **Root cause:** (1) `:root` had color/radius/motion tokens but **no +type scale** — every component hardcoded its own size, so nothing ranked; (2) `--accent` +and `--mono` had no discipline — accent was sprayed on logo/code/CTA/links, mono was on the +whole event strip + "LIVE"/"Set name"/helpline (human-readable prose, not machine IDs); +(3) the first viewport re-explained the room 4× and leaked host/debug labels. + +Changes (presentational + copy only — send/render path, dedup, `data-sn-live` untouched): +- **Type scale** added to `:root` (`--fs-display/title/base/sub/label/mono`); one + `--fs-display` per screen. Hero 26px→22px; empty-state title becomes the one 18px display + element. **Mono reserved for machine IDs only** (room code + `/ask`) — de-mono'd the event + strip, "LIVE ROOM" divider, menu section headers, "Set name", "what is this?". +- **Wordmark bug fixed:** "Scratch Node" rendered spaced because `.h-logo` is `flex; gap:6px` + and the markup split "Scratch" / `Node` into two flex items — the gap meant for + [dot · wordmark] split the wordmark. Wrapped it in `.h-logo-word` → renders "ScratchNode". +- **Room code** is now a quiet muted mono chip (was a heavy accent-bordered button); menu is + a borderless icon. **Accent reserved for the one primary action** (send). +- **Event strip:** removed `· 0 FAQ`; gated `●Event` mode + `L0 Manual` capture (host/debug + controls that leaked) to `data-role="host"` (elements stay in DOM — JS still reads them). +- **De-duped:** dropped the hero's joined-count (lives once, in the strip) and "Disposable + event brain" → "Live event log · public wiki when it ends" (static + JS rewrite both). + Welcome onboarding banner quieted (no accent card) and hidden on mobile. +- **Composer:** placeholder "Public chat… or /ask for a sourced answer" → "Message or /ask…" + (fixes the clipped-placeholder polish bug — the rendered text came from JS at the mode-driven + default, not the static markup). Helpline collapsed from 2 lines to one. Privacy state shows + text "Public" / "Private 🔒" instead of an ambiguous open-lock 🔓 glyph next to "public". +- **Empty state:** removed the giant "Ask the first question →" accent CTA (the composer IS the + CTA — no duplicate); copy now teaches all three actions (message · `/ask` · private note). +- **Keyboard-open fix:** a `visualViewport` listener sets `--keyboard-offset`; the fixed mobile + composer pins above the keyboard and the footer + welcome collapse while typing + (`data-input-focused`) — kills the "footer leaking behind the keyboard" issue. +- **Menu:** "Continue in NodeBench" gated to named users (was showing to anonymous guests under + the hidden "Your notes" header); "Keyboard shortcuts" hidden on the mobile sheet. + +Verified: before/after + keyboard-open + menu screenshots at 390px; DOM read-backs (wordmark +"ScratchNode", placeholder, privacy text, gated menu items); `--keyboard-offset` lifts composer ++ hides footer; **static launch scan PASS (0 blockers/warnings)**; no console errors. Live-mode +scanner placeholder/affordance asserts confirmed by reading (`/ask` present, work/sensitive +placeholders untouched, Chat/notes/Open wiki/room-code affordances preserved). + +**Commit**: `this commit`. **Author**: Homen Shum + Claude. + ## 2026-06-03 — Honesty: stop the 4 "Continue in NodeBench" CTAs from 404'ing A scoping audit caught a live HONEST_STATUS lie: **four** in-room CTAs ("Continue in NodeBench", "Open in NodeBench", "Open NodeBench event notebook", "Continue this event diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 260e8e46..ace854e6 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -54,6 +54,18 @@ --purple: #a78bfa; --ui: "Manrope", system-ui, -apple-system, sans-serif; --mono: "JetBrains Mono", "SF Mono", Menlo, monospace; + /* ─── Type scale (one ladder, so the eye can rank) ─── + Discipline: ONE --fs-display per screen. --mono is reserved for machine + identifiers ONLY (room code + the /ask token), never human-readable prose. + --accent (solid terracotta) marks the SINGLE primary action on a screen; + everything else uses --accent-ghost or a neutral border. */ + --fs-display: 22px; /* the one large element: empty-state title / hero */ + --fs-title: 15px; /* event title, menu row, answer heads */ + --fs-base: 14px; /* chat + body */ + --fs-sub: 12px; /* metadata, helper, status */ + --fs-label: 11px; /* tracked caps section headers */ + --fs-mono: 11px; /* room code + /ask token only */ + --accent-ghost: rgba(217,119,87,.10); --r: 12px; --r-sm: 8px; --motion-fast: 120ms; @@ -114,22 +126,25 @@ .h-logo { display: flex; align-items: center; gap: 6px; font-weight: 700; font-size: 14px; } .h-logo-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px rgba(217,119,87,.7); animation: logoBreathe 3.4s ease-in-out infinite; } @keyframes logoBreathe { 0%,100% { transform: scale(1); box-shadow: 0 0 10px rgba(217,119,87,.62); } 50% { transform: scale(1.28); box-shadow: 0 0 18px rgba(217,119,87,.92); } } -.h-logo span { color: var(--accent); } +.h-logo-word { display: inline-flex; } /* keeps "ScratchNode" as one word — the .h-logo gap must not split it */ +.h-logo-word > span { color: var(--accent); } .h-live { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 100px; background: rgba(94,168,103,.1); border: 1px solid rgba(94,168,103,.25); font-family: var(--mono); font-size: 10px; font-weight: 700; color: var(--green); letter-spacing: .1em; } .h-live::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px rgba(94,168,103,.7); animation: livePulse 2s infinite; } @keyframes livePulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .42; transform: scale(.75); } } @media (prefers-reduced-motion: reduce) { body::before, .h-logo-dot, .h-live::before { animation: none; } } .h-spacer { flex: 1; } +/* Room code = quiet machine-id chip (mono is allowed here — it IS an identifier). + NOT accent: accent is reserved for the one primary action on screen. */ .h-code { - min-height: 36px; padding: 6px 14px; border-radius: var(--r-sm); - background: transparent; border: 1px solid var(--line); - color: var(--accent); font-family: var(--mono); font-size: 12px; font-weight: 700; - letter-spacing: .18em; cursor: pointer; transition: border-color .12s; + min-height: 30px; padding: 4px 11px; border-radius: 100px; + background: rgba(255,255,255,.04); border: 1px solid var(--line); + color: var(--ink-muted); font-family: var(--mono); font-size: var(--fs-mono); font-weight: 600; + letter-spacing: .14em; cursor: pointer; transition: border-color .12s, color .12s; } -.h-code:hover { border-color: var(--accent); } -.h-menu { width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--r-sm); border: 1px solid var(--line); background: transparent; color: var(--ink-muted); } -.h-menu:hover { color: var(--ink); border-color: var(--ink-faint); } -@media (max-width: 540px) { .h-menu { width: 44px; height: 44px; } .h-code { min-height: 44px; } } +.h-code:hover { border-color: var(--ink-faint); color: var(--ink); } +.h-menu { width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--r-sm); border: 0; background: transparent; color: var(--ink-faint); } +.h-menu:hover { color: var(--ink); background: rgba(255,255,255,.05); } +@media (max-width: 540px) { .h-menu { width: 44px; height: 44px; } .h-code { min-height: 36px; } } /* ─── Event identity strip (persistent after scroll) ─── */ .event-strip { @@ -139,26 +154,35 @@ background: linear-gradient(180deg, rgba(21,20,19,.86), rgba(21,20,19,.5)); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border-bottom: 1px solid var(--line); - font-family: var(--mono); font-size: 10px; color: var(--ink-muted); - letter-spacing: .04em; + font-family: var(--ui); font-size: var(--fs-sub); color: var(--ink-muted); + letter-spacing: 0; overflow-x: auto; white-space: nowrap; scrollbar-width: none; } .event-strip::-webkit-scrollbar { display: none; } -.event-strip b { color: var(--ink); font-weight: 700; font-family: var(--ui); font-size: 11px; letter-spacing: 0; } +.event-strip b { color: var(--ink); font-weight: 700; font-family: var(--ui); font-size: var(--fs-sub); letter-spacing: 0; } .event-strip .dot { color: var(--ink-faint); } +/* Host/debug controls (event mode, capture level, FAQ count) are NOT for public + attendees — they leaked into the public strip. Gate them to the host role. + Elements stay in the DOM (JS reads ev-faq-count / ev-cap-label / ev-mode-label). */ +.event-strip .ev-mode, +.event-strip .ev-cap, +.event-strip .ev-host-only { display: none; } +body[data-role="host"] .event-strip .ev-mode, +body[data-role="host"] .event-strip .ev-cap { display: inline-flex; } +body[data-role="host"] .event-strip .ev-host-only { display: inline; } .event-strip .ev-link { margin-left: auto; - color: var(--accent); border: 1px solid rgba(217,119,87,.3); - padding: 2px 8px; border-radius: 100px; + color: var(--ink-muted); border: 1px solid var(--line); + padding: 3px 10px; border-radius: 100px; text-decoration: none; cursor: pointer; flex-shrink: 0; } -.event-strip .ev-link:hover { background: rgba(217,119,87,.08); } +.event-strip .ev-link:hover { color: var(--ink); border-color: var(--ink-faint); } @media (max-width: 540px) { - .event-strip { padding: 5px 14px; font-size: 9px; gap: 8px; } - .event-strip b { font-size: 10px; } - .event-strip .ev-link { font-size: 9px; } + .event-strip { padding: 6px 14px; font-size: var(--fs-sub); gap: 8px; } + .event-strip b { font-size: var(--fs-sub); } + .event-strip .ev-link { font-size: var(--fs-label); } } /* ─── Main (single column) ─── */ @@ -174,9 +198,9 @@ @media (prefers-reduced-motion: reduce) { main.m { animation: none; } } /* ─── Hero (small, scannable) ─── */ -.hero { margin-bottom: 20px; } -.hero :is(h1, h2) { font-size: 26px; font-weight: 800; letter-spacing: -.02em; margin: 0 0 4px; } -.hero-meta { font-size: 13px; color: var(--ink-muted); } +.hero { margin-bottom: 18px; } +.hero :is(h1, h2) { font-size: var(--fs-display); font-weight: 800; letter-spacing: -.02em; margin: 0 0 4px; } +.hero-meta { font-size: var(--fs-sub); color: var(--ink-muted); } .hero-meta b { color: var(--ink); font-weight: 600; } /* ─── Composer (gravitational center) ─── */ @@ -242,6 +266,7 @@ border: 1px solid var(--line); } .c-helpline .pill.ask { color: var(--accent); border-color: rgba(217,119,87,.3); background: rgba(217,119,87,.06); } +.c-helpline .privacy-state { margin-left: auto; font-size: var(--fs-label); font-weight: 600; color: var(--ink-muted); } body[data-mode="private"] .c-helpline .privacy-state { color: var(--purple); } /* Role-gated host-only actions on agent cards */ @@ -272,7 +297,7 @@ /* ─── Feed ─── */ .feed { display: flex; flex-direction: column; gap: 4px; } -.feed-divider { display: flex; align-items: center; gap: 10px; margin: 8px 0 4px; font-family: var(--mono); font-size: 10px; color: var(--ink-faint); letter-spacing: .12em; text-transform: uppercase; } +.feed-divider { display: flex; align-items: center; gap: 10px; margin: 8px 0 4px; font-family: var(--ui); font-size: var(--fs-label); font-weight: 600; color: var(--ink-faint); letter-spacing: .12em; text-transform: uppercase; } .feed-divider::after { content: ''; flex: 1; height: 1px; background: var(--line); } /* Chat row (minimal, dense) */ @@ -631,7 +656,7 @@ .menu-sheet button { min-height: 44px; } .menu-sheet[data-open="true"] { transform: translateY(0); } .menu-sheet-handle { width: 32px; height: 3px; border-radius: 2px; background: var(--line); margin: 0 auto 14px; } -.menu-sheet h4 { margin: 0 0 8px; font-size: 11px; font-weight: 700; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .12em; font-family: var(--mono); } +.menu-sheet h4 { margin: 0 0 8px; font-size: var(--fs-label); font-weight: 700; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .12em; font-family: var(--ui); } .menu-sheet button { display: block; width: 100%; text-align: left; padding: 10px 12px; margin: 2px 0; @@ -654,18 +679,23 @@ .menu-sheet h4[data-show-for]:not([data-show-for="all"]) { display: none; } body[data-role="host"] .menu-sheet h4[data-show-for="host"] { display: block; } body[data-named="true"] .menu-sheet h4[data-show-for="named"] { display: block; } +/* Keyboard shortcuts is a desktop affordance — hide it from the mobile sheet. */ +@media (max-width: 540px) { .menu-sheet .menu-desktop-only { display: none; } } .menu-scrim { position: fixed; inset: 0; z-index: 105; background: rgba(0,0,0,.5); display: none; } .menu-scrim[data-open="true"] { display: block; } /* ─── First-visit welcome banner ─── */ .welcome { - display: none; align-items: center; gap: 10px; padding: 10px 14px; + display: none; align-items: center; gap: 10px; padding: 9px 12px; margin: 0 0 12px; - background: linear-gradient(135deg, rgba(217,119,87,.1), rgba(217,119,87,.02)); - border: 1px solid rgba(217,119,87,.25); border-radius: var(--r-sm); - font-size: 12px; color: var(--ink-muted); + background: rgba(255,255,255,.03); + border: 1px solid var(--line); border-radius: var(--r-sm); + font-size: var(--fs-sub); color: var(--ink-muted); } body[data-new-user="true"] .welcome { display: flex; } +/* Mobile: the empty-state copy + composer already carry the how-to. The banner + is redundant chrome on a small first viewport — hide it. */ +@media (max-width: 540px) { body[data-new-user="true"] .welcome { display: none; } } .welcome-emoji { font-size: 16px; } .welcome-text { flex: 1; line-height: 1.4; } .welcome-text strong { color: var(--ink); } @@ -679,7 +709,7 @@ .id-avatar { width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), #b85f44); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; } .id-text { flex: 1; } .id-text b { color: var(--ink); font-weight: 600; } -.id-set { background: transparent; border: 0; color: var(--accent); font-size: 11px; cursor: pointer; padding: 4px 6px; border-radius: 4px; font-family: var(--mono); } +.id-set { background: transparent; border: 0; color: var(--accent); font-size: var(--fs-sub); cursor: pointer; padding: 4px 6px; border-radius: 4px; font-family: var(--ui); } .id-set:hover { background: rgba(217,119,87,.08); } /* ─── Empty state (when feed has zero rows) ─── */ @@ -691,8 +721,9 @@ body[data-feed-empty="true"] #feed > .row, body[data-feed-empty="true"] #feed > .ans { display: none; } .empty-icon { font-size: 28px; opacity: .6; margin-bottom: 4px; } -.empty-title { font-size: 14px; color: var(--ink); font-weight: 600; } -.empty-body { font-size: 12px; color: var(--ink-muted); max-width: 280px; line-height: 1.5; } /* AA contrast — instructional empty-state copy must be readable */ +.empty-title { font-size: 18px; color: var(--ink); font-weight: 700; letter-spacing: -.01em; } /* the one display element when the feed is empty */ +.empty-body { font-size: var(--fs-sub); color: var(--ink-muted); max-width: 280px; line-height: 1.5; } /* AA contrast — instructional empty-state copy must be readable */ +.empty-body b { color: var(--accent); font-family: var(--mono); font-weight: 600; } /* /ask token */ /* ─── Attention pulses for first-visit ─── */ body[data-new-user="true"] #lock, @@ -732,13 +763,12 @@ /* ─── "What is this" inline link ─── */ .about-link { - display: inline-flex; align-items: center; gap: 4px; - margin-left: 6px; padding: 2px 7px; border-radius: 100px; - background: rgba(255,255,255,.04); border: 1px solid var(--line); - color: var(--ink-faint); font-size: 10px; font-family: var(--mono); letter-spacing: .04em; - text-transform: uppercase; cursor: pointer; transition: all .12s; + display: inline; margin-left: 7px; padding: 0; + background: none; border: 0; + color: var(--ink-faint); font-size: var(--fs-sub); font-family: var(--ui); letter-spacing: 0; + text-transform: none; cursor: pointer; transition: color .12s; } -.about-link:hover { color: var(--ink); border-color: var(--ink-faint); } +.about-link:hover { color: var(--ink); text-decoration: underline; } /* ─── UNIVERSAL FEATURE SHEET (slides up — name, about, share, notes, wiki, people, signin, host, shortcuts) ─── */ .sheet-scrim { position: fixed; inset: 0; z-index: 115; background: rgba(0,0,0,.5); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); display: none; } @@ -984,9 +1014,7 @@ .about-card h4 { margin: 0 0 4px; font-size: 13px; color: var(--accent); font-weight: 700; } .about-card p { margin: 0; font-size: 12px; color: var(--ink-muted); line-height: 1.55; } -/* Empty state CTA */ -.empty-cta { margin-top: 14px; padding: 10px 18px; min-height: 44px; background: var(--accent); color: #fff; border: 0; border-radius: var(--r-sm); font-family: var(--ui); font-size: 13px; font-weight: 600; cursor: pointer; } -.empty-cta:hover { filter: brightness(1.08); } +/* Empty-state CTA removed — the composer IS the call to action (no duplicate button). */ /* Footer (tiny) */ .f { padding: 24px 20px calc(32px + var(--safe-bot)); text-align: center; font-size: 11px; color: var(--ink-faint); } @@ -1001,13 +1029,21 @@ /* Mobile: pin the composer to the bottom (Slack/Discord/iMessage convention — thumb reach, newest message sits right above where you type). Desktop keeps the sticky top command bar. */ .c { - position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 45; + position: fixed; top: auto; left: 0; right: 0; z-index: 45; + /* Pin above the on-screen keyboard — --keyboard-offset is set from + visualViewport when the input is focused (see keyboard-aware script). */ + bottom: var(--keyboard-offset, 0px); margin: 0; padding: 8px calc(14px + var(--safe-right)) calc(8px + var(--safe-bot)) calc(14px + var(--safe-left)); border-top: 1px solid var(--line); background: var(--bg); box-shadow: 0 -8px 24px -12px rgba(0,0,0,.55); + transition: bottom .18s var(--ease-out); } + @media (prefers-reduced-motion: reduce) { .c { transition: none; } } + /* While typing: collapse non-essential chrome so input + feed stay visible above the keyboard. */ + body[data-input-focused="true"] .f, + body[data-input-focused="true"] .welcome { display: none; } .row { grid-template-columns: 32px 1fr; column-gap: 8px; padding: 5px 4px 6px; } .row-avatar { width: 32px; height: 32px; font-size: 12px; } .row-time { font-size: 9px; } @@ -1018,7 +1054,7 @@ .c-mode { padding: 0; width: 26px; min-width: 26px; height: 26px; justify-content: center; gap: 0; } .c-mode #c-mode-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } .c-mode .dot { width: 7px; height: 7px; } - .feed-divider { font-size: 9px; } + .feed-divider { font-size: var(--fs-label); } } /* Landscape (short height) — hide hero, compact composer */ @@ -3297,7 +3333,7 @@

Your wiki is live.

- + LIVE
@@ -3313,8 +3349,8 @@

Your wiki is live.

MCP Auth breakout · 0 joined - · - 0 FAQ + · + 0 FAQ @@ -3333,14 +3369,14 @@

Your wiki is live.

- New to ScratchNode? This is the sidecar room: chat publicly, /ask for sourced answers, or lock the composer for private notes. Take the 20-second tour → + Message normally. Start with /ask for sourced answers · 🔒 saves privately. Tour →

AI Infra Summit

-

Disposable event brain · 0 joined · public wiki later

+

Live event log · public wiki when it ends

@@ -3371,7 +3407,7 @@

AI Infra Summit

@@ -3409,8 +3443,7 @@

AI Infra Summit +
Send a message, ask with /ask for a sourced answer, or 🔒 save a private note.
@@ -3766,7 +3799,7 @@

This event

Your notes

- +

For hosts

@@ -3775,7 +3808,7 @@

Account

- +

More

@@ -3962,7 +3995,7 @@

Keyboard shortcuts

var heroTitle = document.getElementById('sn-event-title-hero'); if (heroTitle) heroTitle.textContent = EVENT_TITLE; var heroMeta = document.getElementById('sn-event-hero-meta'); - if (heroMeta) heroMeta.innerHTML = 'Disposable event brain · 0 joined · code ' + escapeHtml(EVENT_ROOM_CODE); + if (heroMeta) heroMeta.textContent = 'Live event log · public wiki when it ends'; var peopleSub = document.getElementById('menu-people-sub'); if (peopleSub) peopleSub.textContent = 'Live members'; var mode = document.getElementById('ev-mode-label'); @@ -4085,14 +4118,14 @@

Keyboard shortcuts

document.body.setAttribute('data-mode', goingPrivate ? 'private' : 'public'); var input = document.getElementById('ci'); input.placeholder = goingPrivate - ? 'Private note… saves only to your notebook' - : 'Public chat… or /ask for a sourced answer'; + ? 'Save a private note…' + : 'Message or /ask…'; // Sync the mode badge next to the lock var modeLabel = document.getElementById('c-mode-label'); if (modeLabel) modeLabel.textContent = goingPrivate ? 'Private note' : 'Public room'; // Sync the helpline privacy state var privacyState = document.getElementById('c-privacy-state'); - if (privacyState) privacyState.textContent = goingPrivate ? '🔒 private — saves to your notebook' : '🔓 public — everyone in the room sees this'; + if (privacyState) privacyState.textContent = goingPrivate ? 'Private 🔒' : 'Public'; haptic(8); input.focus(); } @@ -7096,16 +7129,49 @@

Keyboard shortcuts

} else if (eventMode === 'work' && bodyMode === 'public') { placeholder = 'Visible to meeting room'; } else if (eventMode === 'event' && bodyMode === 'private') { - placeholder = 'Save a private note (only you can see this)'; + placeholder = 'Save a private note…'; } else { // event + public (default) - placeholder = 'Chat with the room. /ask for sourced answers. 🔒 for private.'; + placeholder = 'Message or /ask…'; } ci.setAttribute('placeholder', placeholder); } // Re-run on resize so mode-driven placeholder updates stay in sync after layout changes. if (typeof window !== 'undefined' && window.addEventListener) window.addEventListener('resize', _updateComposerPlaceholder); +// ─── Keyboard-aware composer (mobile) ─────────────────────────────── +// When the on-screen keyboard opens, the visual viewport shrinks while the +// layout viewport (window.innerHeight) does not. We measure that delta and +// pin the fixed composer above the keyboard via --keyboard-offset, and flag +// data-input-focused so the footer + welcome banner collapse while typing. +// This kills the "footer leaking behind the keyboard" issue on phones. +(function () { + try { + var ci = document.getElementById('ci'); + var root = document.documentElement; + var vv = window.visualViewport || null; + function syncKeyboard() { + if (!vv) return; + var offset = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)); + root.style.setProperty('--keyboard-offset', offset + 'px'); + } + if (vv) { + vv.addEventListener('resize', syncKeyboard); + vv.addEventListener('scroll', syncKeyboard); + } + if (ci) { + ci.addEventListener('focus', function () { + document.body.setAttribute('data-input-focused', 'true'); + syncKeyboard(); + }); + ci.addEventListener('blur', function () { + document.body.removeAttribute('data-input-focused'); + root.style.setProperty('--keyboard-offset', '0px'); + }); + } + } catch (e) { /* visualViewport unsupported — composer stays pinned to bottom */ } +})(); + // Observe data-mode changes so placeholder updates on lock toggle (function() { var body = document.body; From 57d8f3bdd527fe1bf05e576b1060844a68f62762 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 12:52:45 -0700 Subject: [PATCH 91/91] test: guard public photo evidence boundary --- public/proto/home-v5.html | 22 +++++ scripts/scratchnode/scanLaunch.mjs | 20 +++++ .../scratchnode-live-route-honesty.spec.ts | 84 +++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index ab411028..3db5c86d 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -4259,6 +4259,27 @@

Keyboard shortcuts

body.appendChild(chip); return spot; } +// Public photo evidence is a typed event-log marker, not an upload pipeline. +// Real file/media handling belongs behind owner/workspace policy; this chip only +// marks explicitly named public evidence such as "photo: booth placard". +function detectPublicPhotoEvidence(text) { + var value = String(text || ''); + return /\b(photo|screenshot|image|pic)\s*:/i.test(value); +} +function renderPublicPhotoEvidence(row, text) { + if (!row || !detectPublicPhotoEvidence(text)) return false; + row.setAttribute('data-event-log-media', 'photo'); + var body = row.querySelector('.row-body'); + if (!body || body.querySelector('.sn-photo-evidence')) return true; + var chip = document.createElement('span'); + chip.className = 'sn-photo-evidence'; + chip.setAttribute('data-event-log-media', 'photo'); + chip.textContent = 'photo evidence'; + body.appendChild(chip); + return true; +} +window.detectPublicPhotoEvidence = detectPublicPhotoEvidence; +window.renderPublicPhotoEvidence = renderPublicPhotoEvidence; function _clearComposer() { var input = document.getElementById('ci'); input.value = ''; @@ -7686,6 +7707,7 @@

Keyboard shortcuts

} } renderManualLocationSpot(row, msg.text); + if (window.renderPublicPhotoEvidence) window.renderPublicPhotoEvidence(row, msg.text); feedEl.appendChild(row); row.scrollIntoView({ behavior: 'smooth', block: 'end' }); }; diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs index cc3c9435..716fa004 100644 --- a/scripts/scratchnode/scanLaunch.mjs +++ b/scripts/scratchnode/scanLaunch.mjs @@ -900,6 +900,26 @@ function scanPublicRepoReadiness() { plane: "event-log-evidence", detail: files.routeHonestySpec, }); + addCheck({ + ok: + /function\s+detectPublicPhotoEvidence\s*\(/i.test(homeHtml) && + /function\s+renderPublicPhotoEvidence\s*\(/i.test(homeHtml) && + /data-event-log-media["']?,\s*["']photo/i.test(homeHtml) && + /className\s*=\s*["']sn-photo-evidence["']/i.test(homeHtml) && + /public photo evidence markers stay event-log only while private photo follow-ups stay private/i.test( + routeHonestySpec, + ) && + /photo: Booth 12 latency board for #Orbital/i.test(routeHonestySpec) && + /sn-photo-evidence\[data-event-log-media="photo"\]/i.test(routeHonestySpec) && + /privateText\s*=\s*"photo: private sponsor board follow-up for MedLayer buyers"/i.test( + routeHonestySpec, + ) && + /expect\(state\.privateSendCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(state\.actions\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), + name: "event-log route spec covers public photo evidence boundary", + plane: "event-log-evidence", + detail: `${files.homeV5} + ${files.routeHonestySpec}`, + }); addCheck({ ok: /private notes anchored from public messages preserve context without public leakage/i.test(routeHonestySpec) && diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index b28a6444..89bea623 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -1636,6 +1636,90 @@ test.describe("ScratchNode live route honesty", () => { expect(savedState.publicSendCalls).toEqual([]); }); + test("public photo evidence markers stay event-log only while private photo follow-ups stay private", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const publicText = "photo: Booth 12 latency board for #Orbital"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicText); + + const publicRow = page.locator('.row[data-event-log-media="photo"]', { + hasText: publicText, + }); + await expect(publicRow.locator(".row-text")).toContainText(publicText); + await expect(publicRow.locator('.sn-photo-evidence[data-event-log-media="photo"]')).toHaveText( + "photo evidence", + ); + await expect(publicRow.locator('.hashtag[data-event-log-tag="orbital"]')).toHaveText( + "#Orbital", + ); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + await page.evaluate(() => { + if (document.body.getAttribute("data-mode") !== "private") { + (window as any).toggleLock?.(); + } + }); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + + const privateText = "photo: private sponsor board follow-up for MedLayer buyers"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: privateText })).toHaveCount(0); + await expect(page.locator('.row[data-event-log-media="photo"]', { hasText: privateText })).toHaveCount(0); + + const state = await page.evaluate(({ privateText, publicText }) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(privateText), + ); + return { + noteText: String((note?.title || "") + "\n" + (note?.body || "")).replace( + //gi, + "\n", + ), + photoRows: document.querySelectorAll('.row[data-event-log-media="photo"]').length, + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === privateText, + ), + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === publicText, + ), + actions: win.__snMockActions || [], + }; + }, { privateText, publicText }); + + expect(state.noteText).toContain(privateText); + expect(state.photoRows).toBe(1); + expect(state.privateSendCalls).toEqual([]); + expect(state.publicSendCalls).toHaveLength(1); + expect(state.publicSendCalls[0].args.kind).toBe("chat"); + expect(state.actions).toEqual([]); + }); + test("private /ask stays out of the public feed and increases the NodeBench handoff note count", async ({ page, }) => {