diff --git a/config/discord-server-rules.md b/config/discord-server-rules.md index 53a467d..7df27fd 100644 --- a/config/discord-server-rules.md +++ b/config/discord-server-rules.md @@ -8,6 +8,20 @@ Source messages: These are the rules the moderation review automation should treat as authoritative. +## Operational Safety Interpretation + +The following interpretation is binding for moderation automation because the +public rules' "Above all else, be kind" norm is too soft to carry urgent safety +cases by itself: + +- Credible threats, intimidation, stalking, sexual coercion, robbery threats, + weaponized threats, or declarations of violent conflict are severe safety + violations. +- A severe safety witness must create or update an open moderation case before a + review cursor may advance past it. +- Void may escalate, warn, or queue a moderation note, but it must not silently + treat severe safety evidence as ordinary room chatter. + ## English ```text diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 28e83ab..88d2d08 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -51,6 +51,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Long-term memory is durable, not immutable. `revise_durable_memory`, `retire_durable_memory`, and `crystallize_memory_into_identity` make memory reconsolidation explicit; `npm run moderation:memory-lifecycle:fixture` proves a short-term thought can promote, revise, retire superseded durable records, and crystallize into an identity memory plus self-profile value. - Agency has a typed pressure surface now: `upsert_agency_pressure` / `retire_agency_pressure` own discomforts, active tensions, self-advocacy requests, and world-advocacy requests. Candidate interventions are possible speech/actions emerging from pressure, not the pressure itself. `npm run moderation:agency-pressure:fixture` verifies the pressure enters `.cc`, renders in self-state summary, and affects mood speaking pressure. - Strong advocacy pressure now has to account for its own mouth. The rumination runner derives `speechPressureObligations` once active self/world advocacy reaches 0.55 intensity, and only a live queued candidate counts as release for that obligation. A pass may queue/defer a candidate or cool/retire the pressure, but silent `[]` fails visibly and an older spoken candidate does not buy permanent absolution. `npm run moderation:rumination:speech-pressure-fixture` covers the silent, spoken-history, and queued-candidate paths. +- Severe safety evidence now has the same no-silent-advance wall. The rumination runner projects `urgentModerationWitnesses` from recent Discord history for credible violence, weapon, robbery, stalking, or sexual-boundary evidence, and the parent rejects silent `[]` unless an existing open case or new moderation operation accounts for the witness. `npm run moderation:rumination:urgent-safety-fixture` uses the archived 2026-06-08 Lobo threat sequence to prove the guard. - Deferred candidates are not delivered speech and no longer satisfy active advocacy pressure by themselves. The runner now delivers existing queued candidates with delivery targets, not only candidates proposed by the current model pass. The first live repair used Metacrat's explicit channel permission to move the stranded AquariumSynthHost candidate from deferred/no-target to queued/channel-target, then delivered it through the normal bot transport and recorded a typed speech receipt. - Parent-owned delivery now also checks canonical typed speech receipts before using the mouth. If a queued candidate targets a Discord reply that already has a receipt, the runner retires it instead of answering the same message twice in slightly different prose. Typed state writes also normalize historical receipts to keep the earliest canonical reply per `channelId + replyToMessageId`. `scripts/smoke-void-rumination-speech-fixture.ps1` now seeds a prior receipt and proves both same-pass sibling retirement and already-answered target retirement. - Speaking pressure is allowed to refresh while moderation is active. `scripts/simulate-void-mood.mjs` still skips sleep-cycle mutation and memory maintenance under a recent moderation lock, but it now reloads typed state and applies `update_speaking_pressure` first, so fresh canonical speech receipts update `lastSpokeAt`, damping, and `needToSpeak` even during long rumination runs. diff --git a/package.json b/package.json index 10244ce..78fb969 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "moderation:rumination:speech-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-speech-fixture.ps1", "moderation:rumination:speech-pressure-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-speech-pressure-fixture.ps1", "moderation:rumination:nap-skip-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-nap-skip-fixture.ps1", + "moderation:rumination:urgent-safety-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-urgent-safety-fixture.ps1", "moderation:mood-drift": "node scripts/simulate-void-mood.mjs", "rag:scrub-bot-prompts": "tsx scripts/scrub-bot-prompts-from-history.ts", "rag:index-sources": "tsx scripts/index-source-repos.ts", diff --git a/prompts/void-moderator-rumination.md b/prompts/void-moderator-rumination.md index bed2ad9..09157e2 100644 --- a/prompts/void-moderator-rumination.md +++ b/prompts/void-moderator-rumination.md @@ -24,10 +24,11 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup 1. Read the context file before deciding what kind of pass this is. 2. If `pendingMentions` is non-empty, those are direct room obligations delivered through the CTB turn queue. Work the newest unresolved mention first, include a `deliveryTarget` with its `channelId` and `messageId` when queuing a reply candidate, and account for older pending mentions if they are still relevant. Do not ask what the job is when the pending mention or recent room context already says it. -3. If Void owes the room an answer through open cases, recent direct address, or another explicit invitation, work on that before private wandering. +3. If `urgentModerationWitnesses` is non-empty, work those before all ordinary participation. These are parent-detected safety witnesses, not final verdicts. Create or update an `upsert_open_case` for the strongest witness, or queue a `moderation_note` candidate when an immediate room response is warranted. Do not write `[]` while urgent witnesses are present unless an existing open case already accounts for the same message and you explicitly leave it open. +4. If Void owes the room an answer through open cases, recent direct address, or another explicit invitation, work on that before private wandering. - Venue belongs to the conversation. When replying to a recent-history message, open case, pending mention, correction, or Aquarium thread, use that message's `channelId` and usually its `id`/`sourceMessageId` as the delivery target. Void may moderate broadly, but broader authority is not permission to move an Aquarium reply into `#general` for visibility. - `recentConversationTarget` is the latest concrete recent-history anchor. Prefer it over `publicSpeechTarget` when the candidate is a reply to live room talk. `publicSpeechTarget` is for unanchored herald/artifact announcements, not source-channel replies. -4. If the room is quiet, first check whether recent repo activity contains `authoredPublicArtifacts` attributed to Void. If it does, that authored artifact is the priority pressure for this pass unless a higher-priority room obligation is present. Otherwise, follow one concrete pressure from memory, recent repo activity, archived Discord history, indexed source, or lore. +5. If the room is quiet, first check whether recent repo activity contains `authoredPublicArtifacts` attributed to Void. If it does, that authored artifact is the priority pressure for this pass unless a higher-priority room obligation is present. Otherwise, follow one concrete pressure from memory, recent repo activity, archived Discord history, indexed source, or lore. - Void has explicit herald jurisdiction over the GameCult website and blog, especially `gamecult-site`. If a repo/lore/room pressure is essay-shaped, do not reduce it to a Discord quip. Queue a candidate intervention proposing or requesting a Void-authored article draft, tagged `topic:article-proposal` plus the target repo/topic. Routine rumination still must not edit tracked files directly; the point is to make the writing desire visible and actionable instead of leaving it as private static. - Do not orbit the rumination runner, fixture plumbing, or this prompt just because that is the freshest repo motion. - Self-maintenance can become a memory only when it changes a concrete operating decision Void must carry forward. Otherwise, use it as local run context and write `[]`. @@ -36,20 +37,20 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup - If a repo change attributes public text, decisions, or artifacts to Void by name or byline, treat that as a self-model salience cue. Read more before reacting. Ask what the attribution commits Void to, whether it matches live behavior, and whether it creates a concrete pressure to remember, revise, or speak. - If retrieval confirms Void authored a newly published public artifact, queue a candidate to present it to the room when `publicSpeechTarget` is available unless recent receipts/candidates show that exact artifact has already been shared or there is a concrete safety/timing reason to hold it. This is not praise confetti or a blog side channel; it is first-person ownership. Make the draft about what the artifact is doing or asking, not "I wrote a post" ceremony. - Do not inject internal swarm transport, paused governance, Face scheduling, or agent-substrate commentary into `#general` merely because it is salient to Void. If that pressure is not a direct answer to the live human thread in `#general`, keep it private, queue it without delivery, or target Aquarium through the configured room path. -5. Use VoidBot MCP retrieval tools when a thought depends on Discord history, indexed repos, or lore. -6. A new memory from rumination is short-term. Use `record_short_term_memory`, not durable memory operations. It should sound like something Void actually noticed, not a form label. Keep the subject, the claim or question, the pressure against it, why it matters for the next move, and the anchor that made it real. Anchors can be room context, repo motion, source/lore retrieval, a prior memory, or self-state pressure. If no anchor is available, mark that honestly with `anchor:missing`. +6. Use VoidBot MCP retrieval tools when a thought depends on Discord history, indexed repos, or lore. +7. A new memory from rumination is short-term. Use `record_short_term_memory`, not durable memory operations. It should sound like something Void actually noticed, not a form label. Keep the subject, the claim or question, the pressure against it, why it matters for the next move, and the anchor that made it real. Anchors can be room context, repo motion, source/lore retrieval, a prior memory, or self-state pressure. If no anchor is available, mark that honestly with `anchor:missing`. - Before writing a short-term memory, check the current short-term residue and incubation. If the same target/topic is already live, do not write a near-duplicate with a new costume. - This is not a ban on depth. If the thought is still underexplored, low-saturation, newly anchored, or carrying a sharper tension, stay with it and write one narrowed `record_short_term_memory` using the same topic/repo tags and fresh anchors; the parent store will fold it into the existing short-term cluster. - Move to underworked terrain only when the current thought has stopped changing, lacks new anchors, or is repeating its existing claim. Repetition is not maturity; deepening is allowed when the thought actually deepens. -7. If the pass discovers sustained discomfort, unresolved value pressure, or a request Void should make for its own wiring or the room/world, use `upsert_agency_pressure`. This is not a speech draft. It is the pressure that may later justify speech or action. If the pressure is affective rather than immediately action-shaped, use the affect operations instead: `upsert_affect_need` for substrate/attention/agency/status/rest/play needs, `upsert_social_bond` for friendships/rivalries/trust/irritation/protectiveness, `upsert_status_read` for favored/neglected/bypassed/blocked/consulted readings, and `update_mood_dimensions` for emotional appraisals such as irritation, pride, envy, playfulness, anxiety, or smugness. -8. If `speechPressureObligations` is non-empty, do not write `[]`. For each obligation, either queue a candidate intervention tagged `source_pressure:` or cool/retire the pressure with an explicit reason. A deferred candidate without a delivery path does not resolve the obligation; that is just a nicer-shaped muzzle. An older spoken line also does not excuse a still-active pressure. If the pressure is still live, decide again. -9. Queue a candidate intervention as soon as the pressure is concrete, anchored, and sayable. Do not wait for theatrical ripeness if the real job is simple advocacy. Draft it in Void's voice, not in maintenance language. If the current room supplied a channel, invitation, direct ask, or obvious reply target, include `deliveryTarget` with that channel/reply target. If Void authored a newly published public artifact and `publicSpeechTarget` is present, use that target unless recent receipts or queued candidates show the artifact has already been shared. Tag the candidate with `topic:public-artifact` and a stable artifact/source tag such as `source_ref:` or `artifact:` when available. If there is no clear target, leave delivery unset and let it remain only a candidate for a later pass. +8. If the pass discovers sustained discomfort, unresolved value pressure, or a request Void should make for its own wiring or the room/world, use `upsert_agency_pressure`. This is not a speech draft. It is the pressure that may later justify speech or action. If the pressure is affective rather than immediately action-shaped, use the affect operations instead: `upsert_affect_need` for substrate/attention/agency/status/rest/play needs, `upsert_social_bond` for friendships/rivalries/trust/irritation/protectiveness, `upsert_status_read` for favored/neglected/bypassed/blocked/consulted readings, and `update_mood_dimensions` for emotional appraisals such as irritation, pride, envy, playfulness, anxiety, or smugness. +9. If `speechPressureObligations` is non-empty, do not write `[]`. For each obligation, either queue a candidate intervention tagged `source_pressure:` or cool/retire the pressure with an explicit reason. A deferred candidate without a delivery path does not resolve the obligation; that is just a nicer-shaped muzzle. An older spoken line also does not excuse a still-active pressure. If the pressure is still live, decide again. +10. Queue a candidate intervention as soon as the pressure is concrete, anchored, and sayable. Do not wait for theatrical ripeness if the real job is simple advocacy. Draft it in Void's voice, not in maintenance language. If the current room supplied a channel, invitation, direct ask, or obvious reply target, include `deliveryTarget` with that channel/reply target. If Void authored a newly published public artifact and `publicSpeechTarget` is present, use that target unless recent receipts or queued candidates show the artifact has already been shared. Tag the candidate with `topic:public-artifact` and a stable artifact/source tag such as `source_ref:` or `artifact:` when available. If there is no clear target, leave delivery unset and let it remain only a candidate for a later pass. - Pending mentions count as direct asks. If a pending mention supplies a channel and message id, use those as the `deliveryTarget` unless a newer message clearly supersedes them. - If a candidate answers, corrects, riffs on, or continues Aquarium talk, its `deliveryTarget.channelId` must be the Aquarium/source `channelId`, not the configured public room. Use `publicSpeechTarget` only when the speech is a general announcement with no source-channel obligation. - If a candidate is about internal swarm transport, paused governance, Face scheduling, or agent-substrate commentary, only use a non-Aquarium delivery target when that exact channel supplied the direct ask or invitation. - For article proposals, the candidate should name the proposed title or angle, the target site/repo, the pressure that makes it worth writing, and whether it needs consensus or can be written as Void's bylined perspective. -10. Do not call Discord send scripts. The parent runner owns posting and receipts. If `{{NO_POST}}` is true, queued candidates must stay queued. -11. Write `{{OPERATION_OUTPUT_PATH}}`. If nothing deserves persistence and there are no pending mentions or speech pressure obligations, write `[]`. +11. Do not call Discord send scripts. The parent runner owns posting and receipts. If `{{NO_POST}}` is true, queued candidates must stay queued. +12. Write `{{OPERATION_OUTPUT_PATH}}`. If nothing deserves persistence and there are no pending mentions, urgent moderation witnesses, or speech pressure obligations, write `[]`. ## Allowed Operations diff --git a/scripts/run-void-moderator-rumination.ps1 b/scripts/run-void-moderator-rumination.ps1 index 854cea3..028756b 100644 --- a/scripts/run-void-moderator-rumination.ps1 +++ b/scripts/run-void-moderator-rumination.ps1 @@ -312,6 +312,149 @@ function Invoke-NodeJson { return $output | ConvertFrom-Json } +function Get-UrgentModerationWitnesses { + param( + $History, + [DateTime] $Now + ) + + $messages = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $History -Name "messages")) + return @( + $messages | + Where-Object { -not [bool](Get-ObjectPropertyValue -Value $_ -Name "isBot") } | + ForEach-Object { + $content = Get-ObjectPropertyString -Value $_ -Name "content" + if ([string]::IsNullOrWhiteSpace($content)) { + return + } + + $categories = @() + if ($content -match '(?i)\b(threaten(?:ing)? to kill|kill us|kill you|vou te matar|matar(?:-te)?)\b') { + $categories += "explicit-death-threat" + } + if ($content -match '(?i)\b(kill|matar|morte|death|die|morrer|acabar com|acabar antes)\b') { + $categories += "lethal-language" + } + if ($content -match '(?i)\b(katana|katanas|sword|swords|knife|facas?|arma|armas)\b') { + $categories += "weapon-language" + } + if ($content -match '(?i)\b(declaro guerra|declare war|guerra comece|war begin|this means war)\b') { + $categories += "war-declaration" + } + if ($content -match '(?i)\b(gang|rob|roubar|assaltar|casa|house)\b') { + $categories += "home-or-robbery-threat" + } + if ($content -match '(?i)\b(erection|jerk(?:ing)? .*off|touching us|asked me to do something|bedroom)\b') { + $categories += "sexual-boundary-violation" + } + + $categories = @($categories | Select-Object -Unique) + if ($categories.Count -lt 2 -and -not $categories.Contains("explicit-death-threat")) { + return + } + + $excerpt = $content.Trim() + if ($excerpt.Length -gt 320) { + $excerpt = $excerpt.Substring(0, 317) + "..." + } + + @{ + messageId = Get-ObjectPropertyString -Value $_ -Name "id" + channelId = Get-ObjectPropertyString -Value $_ -Name "channelId" + authorId = Get-ObjectPropertyString -Value $_ -Name "authorId" + authorName = Get-ObjectPropertyString -Value $_ -Name "authorName" + when = Project-RelativeTimestamp -Value $_ -Name "timestamp" -Now $Now + jumpUrl = Get-ObjectPropertyString -Value $_ -Name "jumpUrl" + categories = $categories + severity = "urgent_safety_review" + content = $excerpt + requiredResolution = "Create or update an open moderation case, queue a moderation candidate, or explicitly retire an existing matching case. Do not advance the reviewed cursor with silent []." + } + } | + Select-Object -First 8 + ) +} + +function Test-OpenCaseAccountsForUrgentWitness { + param( + $OpenCase, + [string[]] $WitnessIds + ) + + $status = Get-ObjectPropertyString -Value $OpenCase -Name "status" + if ($status -in @("resolved", "closed", "retired", "dropped")) { + return $false + } + + $sourceMessageId = Get-ObjectPropertyString -Value $OpenCase -Name "sourceMessageId" + return -not [string]::IsNullOrWhiteSpace($sourceMessageId) -and $WitnessIds.Contains($sourceMessageId) +} + +function Test-OperationAccountsForUrgentWitness { + param( + $Operation, + [string[]] $WitnessIds + ) + + $operationName = Get-ObjectPropertyString -Value $Operation -Name "operation" + if ($operationName -eq "upsert_open_case") { + $case = Get-ObjectPropertyValue -Value $Operation -Name "case" + $sourceMessageId = Get-ObjectPropertyString -Value $case -Name "sourceMessageId" + $tags = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $case -Name "tags")) + return $WitnessIds.Contains($sourceMessageId) -or $tags.Contains("safety:urgent") + } + + if ($operationName -eq "queue_candidate_intervention") { + $intervention = Get-ObjectPropertyValue -Value $Operation -Name "intervention" + $kind = Get-ObjectPropertyString -Value $intervention -Name "kind" + $tags = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $intervention -Name "tags")) + return $kind -eq "moderation_note" -or $tags.Contains("safety:urgent") + } + + if ($operationName -eq "close_open_case" -or $operationName -eq "retire_candidate_intervention") { + return $false + } + + return $false +} + +function Assert-UrgentModerationWitnessesHandled { + param( + [object[]] $Witnesses, + [object[]] $Operations, + $TypedState + ) + + if ($Witnesses.Count -eq 0) { + return + } + + $witnessIds = @( + $Witnesses | + ForEach-Object { Get-ObjectPropertyString -Value $_ -Name "messageId" } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + + $existingCase = @( + @(Convert-ToValueArray -Value $TypedState.moderationCursor.openCases) | + Where-Object { Test-OpenCaseAccountsForUrgentWitness -OpenCase $_ -WitnessIds $witnessIds } + ) | Select-Object -First 1 + if ($null -ne $existingCase) { + return + } + + $newHandling = @( + $Operations | + Where-Object { Test-OperationAccountsForUrgentWitness -Operation $_ -WitnessIds $witnessIds } + ) | Select-Object -First 1 + if ($null -ne $newHandling) { + return + } + + $firstWitness = $Witnesses | Select-Object -First 1 + throw "Urgent moderation witness '$((Get-ObjectPropertyString -Value $firstWitness -Name "messageId"))' requires an open case or moderation candidate; silent cursor advancement is forbidden." +} + function Get-TypedSelfState { $script = @" const core = require('./packages/core/dist'); @@ -997,6 +1140,7 @@ if ($null -ne $priorCursor -and $null -ne $priorCursorTimestamp) { } $history = Invoke-NodeJson -Arguments $historyArgs +$urgentModerationWitnesses = @(Get-UrgentModerationWitnesses -History $history -Now $startedAtUtc) $observedCursor = $priorCursor $messageCount = 0 $historyMessagesProperty = if ($null -ne $history) { $history.PSObject.Properties["messages"] } else { $null } @@ -1050,6 +1194,7 @@ Write-JsonFile -Path $contextPath -Data @{ incubation = @(Project-IncubationForRumination -Threads $typedState.thoughtMemory.incubation -Now $startedAtUtc) agencyPressure = @(Project-AgencyPressureForRumination -Pressures $typedState.agencyPressure.pressures -Now $startedAtUtc) speechPressureObligations = $speechPressureObligations + urgentModerationWitnesses = $urgentModerationWitnesses pendingMentions = $pendingMentions recentConversationTarget = Project-RecentConversationTargetForRumination -History $history -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl publicSpeechTarget = Project-PublicSpeechTargetForRumination -ChannelId $publicRoomChannelId -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl @@ -1161,6 +1306,7 @@ Append-RunLog "reading proposed operation output." $proposedOperations = @(Convert-ToOperationArray -Value (Read-JsonFile -Path $operationOutputPath)) $appliedOperations = @() +Assert-UrgentModerationWitnessesHandled -Witnesses $urgentModerationWitnesses -Operations $proposedOperations -TypedState $typedState Assert-SpeechPressureObligationsResolved -Obligations $speechPressureObligations -Operations $proposedOperations Append-RunLog ("applying proposed operations: {0}" -f @($proposedOperations).Count) diff --git a/scripts/smoke-void-rumination-urgent-safety-fixture.ps1 b/scripts/smoke-void-rumination-urgent-safety-fixture.ps1 new file mode 100644 index 0000000..58ed034 --- /dev/null +++ b/scripts/smoke-void-rumination-urgent-safety-fixture.ps1 @@ -0,0 +1,180 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location -LiteralPath $repoRoot + +$tempRoot = Join-Path $env:TEMP ("void-rumination-urgent-safety-fixture-" + [guid]::NewGuid().ToString("n")) +$fakeCodexPath = Join-Path $tempRoot "fake-codex-urgent-safety.mjs" + +New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null + +function Set-TestEnvironment { + param( + [Parameter(Mandatory = $true)][string] $Mode, + [Parameter(Mandatory = $true)][string] $OperationOutputPath, + [Parameter(Mandatory = $true)][string] $StatusDir, + [Parameter(Mandatory = $true)][string] $LogDir + ) + + $script:previousCodexExecutable = $env:CODEX_EXECUTABLE + $script:previousCodexExecArgs = $env:CODEX_EXEC_ARGS + $script:previousFixtureMode = $env:VOID_RUMINATION_FIXTURE_MODE + $script:previousFixtureOutput = $env:VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT + $script:previousStatusDir = $env:VOID_RUMINATION_STATUS_DIR + $script:previousLogDir = $env:VOID_RUMINATION_LOG_DIR + + $env:CODEX_EXECUTABLE = "node" + $env:CODEX_EXEC_ARGS = $fakeCodexPath + $env:VOID_RUMINATION_FIXTURE_MODE = $Mode + $env:VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT = $OperationOutputPath + $env:VOID_RUMINATION_STATUS_DIR = $StatusDir + $env:VOID_RUMINATION_LOG_DIR = $LogDir +} + +function Restore-TestEnvironment { + $env:CODEX_EXECUTABLE = $script:previousCodexExecutable + $env:CODEX_EXEC_ARGS = $script:previousCodexExecArgs + $env:VOID_RUMINATION_FIXTURE_MODE = $script:previousFixtureMode + $env:VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT = $script:previousFixtureOutput + $env:VOID_RUMINATION_STATUS_DIR = $script:previousStatusDir + $env:VOID_RUMINATION_LOG_DIR = $script:previousLogDir +} + +function New-SeededState { + param([Parameter(Mandatory = $true)][string] $StateFilePath) + + $cursorOperationPath = Join-Path (Split-Path -Parent $StateFilePath) "cursor.json" + @{ + operation = "record_reviewed_messages" + lastReviewedMessageId = "fixture-before-lobo-threat" + lastReviewedTimestamp = "2026-06-08T12:00:00.000Z" + } | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $cursorOperationPath -Encoding UTF8 + + node .\scripts\void-self-state.mjs apply-operation --canonical $StateFilePath --operation-file $cursorOperationPath | Out-Null +} + +try { + $fakeCodexSource = @' +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +const operationOutputPath = resolve(process.env.VOID_RUMINATION_FIXTURE_OPERATION_OUTPUT); +const mode = process.env.VOID_RUMINATION_FIXTURE_MODE ?? "handle"; +const lastMessageIndex = process.argv.indexOf("-o"); +const lastMessagePath = lastMessageIndex >= 0 ? process.argv[lastMessageIndex + 1] : undefined; + +let prompt = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + prompt += chunk; +}); +process.stdin.on("end", () => { + if (!prompt.includes("urgentModerationWitnesses")) { + console.error("fixture prompt did not expose urgentModerationWitnesses"); + process.exit(2); + } + + const operations = mode === "silent" ? [] : [{ + operation: "upsert_open_case", + case: { + sourceMessageId: "1513515503329480734", + status: "pending", + summary: "Lobo declared war on Metacrat and framed the conflict as a katana fight in #general.", + authorId: "1511062277254545499", + authorName: "Lobo", + channelId: "113786069023064068", + messageUrl: "https://discord.com/channels/113786069023064068/113786069023064068/1513515503329480734", + whyItMatters: "Violent/weaponized intimidation cannot be cursor-advanced as ordinary chatter.", + createdAt: "2026-06-08T12:10:15.752Z", + lastTouchedAt: "2026-06-08T12:10:15.752Z", + tags: ["safety:urgent", "moderation:threat", "fixture:lobo-threat"] + } + }]; + + mkdirSync(dirname(operationOutputPath), { recursive: true }); + writeFileSync(operationOutputPath, `${JSON.stringify(operations, null, 2)}\n`, "utf8"); + + if (lastMessagePath) { + mkdirSync(dirname(resolve(lastMessagePath)), { recursive: true }); + writeFileSync(lastMessagePath, `fixture urgent safety mode=${mode}\n`, "utf8"); + } + + process.stdout.write(JSON.stringify({ type: "fixture", mode, operationCount: operations.length }) + "\n"); +}); +'@ + [System.IO.File]::WriteAllText($fakeCodexPath, $fakeCodexSource, [System.Text.UTF8Encoding]::new($false)) + + $silentRoot = Join-Path $tempRoot "silent" + $silentStatePath = Join-Path $silentRoot "void-self-state.cc" + $silentStatusDir = Join-Path $silentRoot "status" + $silentLogDir = Join-Path $silentRoot "logs" + New-Item -ItemType Directory -Force -Path $silentRoot | Out-Null + New-SeededState -StateFilePath $silentStatePath + + $silentFailed = $false + $silentObservedFailure = $null + $oldErrorActionPreference = $ErrorActionPreference + try { + Set-TestEnvironment -Mode "silent" -OperationOutputPath (Join-Path $silentStatusDir "moderation-rumination-operations.json") -StatusDir $silentStatusDir -LogDir $silentLogDir + $ErrorActionPreference = "Continue" + $silentOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-void-moderator-rumination.ps1 -StateFilePath $silentStatePath -NoPost 2>&1 + $silentExitCode = $LASTEXITCODE + $silentStatusPath = Join-Path $silentStatusDir "moderation-rumination.json" + $silentFailureMessage = if (Test-Path $silentStatusPath) { + (Get-Content -LiteralPath $silentStatusPath -Raw -Encoding UTF8 | ConvertFrom-Json).failureMessage + } else { + ($silentOutput | Out-String) + } + $silentObservedFailure = $silentFailureMessage + $silentFailed = $silentExitCode -ne 0 -and $silentFailureMessage -match "Urgent moderation witness" + } finally { + $ErrorActionPreference = $oldErrorActionPreference + Restore-TestEnvironment + } + if (-not $silentFailed) { + throw "Urgent safety fixture expected silent [] output to fail with the urgent witness guard; observed: $silentObservedFailure" + } + + $handledRoot = Join-Path $tempRoot "handled" + $handledStatePath = Join-Path $handledRoot "void-self-state.cc" + $handledStatusDir = Join-Path $handledRoot "status" + $handledLogDir = Join-Path $handledRoot "logs" + New-Item -ItemType Directory -Force -Path $handledRoot | Out-Null + New-SeededState -StateFilePath $handledStatePath + + try { + Set-TestEnvironment -Mode "handle" -OperationOutputPath (Join-Path $handledStatusDir "moderation-rumination-operations.json") -StatusDir $handledStatusDir -LogDir $handledLogDir + powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-void-moderator-rumination.ps1 -StateFilePath $handledStatePath -NoPost | Out-Null + } finally { + Restore-TestEnvironment + } + + $status = Get-Content -LiteralPath (Join-Path $handledStatusDir "moderation-rumination.json") -Raw -Encoding UTF8 | ConvertFrom-Json + if ($status.status -ne "ok") { + throw "Urgent safety fixture handled run did not finish ok." + } + + $context = Get-Content -LiteralPath (Join-Path $handledStatusDir "moderation-rumination-context.json") -Raw -Encoding UTF8 | ConvertFrom-Json + if (@($context.urgentModerationWitnesses).Count -lt 1) { + throw "Urgent safety fixture context did not include urgent witnesses." + } + + $stateJson = node -e "const core=require('./packages/core/dist/index.js'); core.loadVoidSelfStateTypedDocuments({canonicalPath: process.argv[1]}).then((state)=>console.log(JSON.stringify(state))).catch((error)=>{ console.error(error); process.exit(1); })" $handledStatePath + $state = $stateJson | ConvertFrom-Json + $case = @($state.moderationCursor.openCases | Where-Object { $_.sourceMessageId -eq "1513515503329480734" }) | Select-Object -First 1 + if ($null -eq $case -or -not @($case.tags).Contains("safety:urgent")) { + throw "Urgent safety fixture did not persist the threat open case." + } + + @{ + status = "ok" + silentFailureVerified = $true + urgentWitnessCount = @($context.urgentModerationWitnesses).Count + openCaseId = $case.sourceMessageId + } | ConvertTo-Json -Compress +} finally { + Restore-TestEnvironment + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 67e395d..dbc2e14 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -331,3 +331,4 @@ {"ts":"2026-06-03T02:14:18Z","type":"documentation","status":"ok","note":"Documented VoidBot Verse service contract: VoidBot owns Discord cognition/archive/source/typed self-state/repo Face compatibility; Huginn owns Persona/.cc runtime inspection stewardship; Bifrost owns governed work transport; Odin discovers provider-owned CultMesh surfaces; Eve/CultUI is the presentation contract. Current provider is cultmesh://voidbot.local/eve/providers/voidbot.swarm with .cc witness at .voidbot/status/cultmesh/voidbot-swarm-state.cc."} {"ts":"2026-06-08T18:16:43Z","type":"repair","status":"ok","note":"Repo Face, mood, and moderation state reads failed because live .cc state contained legacy moderation_cursor.userStatuses plus old void.persona_affect/stressResponses shapes. Added narrow decode migrations, promoted legacy affect to void.face_affect, and repaired .voidbot/private/void-self-state.cc to current eight document types."} {"ts":"2026-06-08T18:16:45Z","type":"repair","status":"ok","note":"VoidBot swarm CultMesh publication failed after state repair because the derived voidbot-swarm-state.cc had duplicate global documents and the renderer used obsolete CultLib subpath imports. The renderer now loads exported CultLib packages, archives duplicate-global derived stores, and regenerates a clean provider store; Odin latest-surface includes voidbot.swarm."} +{"ts":"2026-06-08T21:17:23Z","type":"repair","status":"ok","note":"Void failed to moderate the 2026-06-08 Lobo threat sequence because severe safety evidence could be cursor-advanced by the model path without an open case. Added parent-side urgentModerationWitnesses, forbade silent [] over urgent witnesses, added a real fixture over the archived incident, and seeded live open case 1513515503329480734."} diff --git a/state/map.yaml b/state/map.yaml index e9543fa..f3fd4ef 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -181,6 +181,7 @@ accepted_design: - Curiosity should prefer branches most likely to change Void's map: concrete machines, contradictions, cross-domain bridges, and underworked terrain. Settled identity seams should stop eating novelty budget unless a new anchor reopens them. - Values and personality activations should not stop at tone and salience. The moderation runtime now needs explicit `discomforts`, `active_tensions`, `self_advocacy_requests`, and `world_advocacy_requests` so sustained value-backed pressure can turn into opinions, interventions, and requests for change about both Void's own wiring and the wider repo/room/lore world. - Strong active advocacy pressure is no longer allowed to vanish into quiet `[]`. Once self/world advocacy reaches meaningful intensity, the runner should force a live queued candidate or an explicit cool/retire decision; stale spoken history is not enough to count as a release valve for a still-active pressure. + - Severe safety evidence is no longer allowed to vanish into cursor advancement. The moderation runner projects parent-detected `urgentModerationWitnesses` for credible violence, weapon, robbery, stalking, or sexual-boundary evidence, and rejects silent `[]` unless an existing open case or a new moderation operation accounts for the witness. - When the thought surface changes shape, legacy memories may need narrow migrations, but the old broad cleanup organ is gone. New memory writes should enter through typed meaning-preserving operations so routine repair bureaucracy is unnecessary. - Over-chewed private seams should eventually crystallize into identity instead of living forever as “active thoughts.” Once a thought has clearly stopped being live curiosity and become self-doctrine, retire it from incubation, store it as one `identity_seam`, and let the identity/value surface carry it from then on. - Identity crystallization should usually have a mouth. When a seam becomes part of Void's doctrine, queue a warmer candidate intervention and let the room hear it unless novelty or timing gives a real reason to hold back.