diff --git a/.env.example b/.env.example index a97d101..6f8e9d9 100644 --- a/.env.example +++ b/.env.example @@ -109,5 +109,6 @@ VOIDBOT_HEALTHCHECK_TASK_NAME=VoidBot Operations Watchdog VOIDBOT_HEALTHCHECK_INTERVAL_MINUTES=60 VOIDBOT_MODERATION_TASK_NAME=Void Moderator Rumination VOIDBOT_MODERATION_INTERVAL_MINUTES=15 -VOID_MODERATION_ENFORCEMENT_MODE=log_only +VOIDBOT_MODERATION_HEARTBEAT_INTERVAL_MINUTES=1 +VOID_MODERATION_ENFORCEMENT_MODE=policy VOID_MODERATION_TIMEOUT_MINUTES=60 diff --git a/config/discord-server-rules.md b/config/discord-server-rules.md index 7df27fd..94f0d35 100644 --- a/config/discord-server-rules.md +++ b/config/discord-server-rules.md @@ -22,6 +22,37 @@ cases by itself: - Void may escalate, warn, or queue a moderation note, but it must not silently treat severe safety evidence as ordinary room chatter. +## Enforcement Policy + +The moderation heartbeat must classify each infringement with exactly one +`infringement:` tag plus either `moderation:instaban`, +`moderation:strike`, or `moderation:case_only`. + +Strike counts are per user and per infringement type. A strike expires after the +type-specific window below. If a user reaches three unexpired strikes for the +same infringement type, the third strike is a ban. Instant-ban cases skip the +strike ladder. + +| Infringement type | Instant-ban condition | Strike condition | Strike expiry | +| --- | --- | --- | --- | +| `safety_threat` | Credible threat of violence, declaration of violent conflict, robbery threat, or real-world harm. | Ambiguous intimidation or aggressive threat-adjacent post that is not yet credible. | 365 days | +| `weaponized_intimidation` | Weapons invoked as a threat, challenge, coercion, or declaration of conflict. | Weapon talk used to posture at a person without a credible immediate threat. | 365 days | +| `stalking_or_doxxing` | Doxxing, tracking, watching, showing up, or threatening offline contact without consent. | Boundary-pushing surveillance talk or repeated unwanted location/contact probing. | 365 days | +| `sexual_boundary_violation` | Sexual coercion, non-consensual sexual content, sexual threats, or sexualized harassment. | Sexual comments outside consent/channel norms that stop short of coercion or threat. | 365 days | +| `bigotry_identity_attack` | Slurs, dehumanization, identity denial, eliminationist rhetoric, or agreeing with a harmful identity sentiment when challenged. | Identity disrespect, targeted stereotyping, or baiting that does not meet the instant-ban bar. | 180 days | +| `bad_faith_argument` | Deliberate malicious impersonation, fabricated evidence, or harmful ironic sentiment affirmed when challenged. | Hypocritical misrepresentation, sealioning, quote twisting, or rhetorical bad faith after correction. | 90 days | +| `nsfw_channel_violation` | Illegal, non-consensual, exploitative, or shock sexual content. | NSFW material outside `#degeneracy`, or low-effort porn in `#degeneracy` with no expressive point. | 30 days | +| `spam_or_deceptive_promotion` | Scam, malware, phishing, or fraudulent promotion. | Repeated ads, self-promotion with misleading claims, or promotion that ignores moderator correction. | 30 days | +| `moderator_obstruction` | Evading, blocking, ignoring, or instructing others to evade active moderator action during a safety issue. | Ignoring moderator requests, continuing after a moderation stop, or procedural obstruction. | 90 days | +| `empty_words_noise` | Coordinated flooding or harassment through low-content agreement/disagreement. | Repeated empty agreement/disagreement after being asked to use reactions or add a point. | 14 days | +| `values_debate_escalation` | Harassment or coercion after a values discussion has been told to stop. | Continuing a preference/value argument after moderator de-escalation. | 14 days | +| `pg13_language_violation` | Threatening, hateful, or sexual explicit language covered by a stronger instant-ban type. | Excessive strong language in non-18+ spaces without artistic purpose. | 14 days | +| `event_time_coordination` | No instant-ban path unless deception or harassment triggers another type. | Repeated event coordination times not given in GMT after correction. | 14 days | + +If more than one type applies, choose the strongest type whose instant-ban or +strike condition is actually supported by the message evidence. Do not multiply +strikes for the same message. + ## English ```text diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index ee204da..4468b57 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -17,7 +17,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou ## Current Shape - Discord bot and worker are live. -- The unattended repo Face / CTB swarm is live again as of 2026-05-21 after the teardown/restart pass. `GameCult Local Orchestrator` is enabled and owns the single hidden pulse for repo Face CTB turns, Void mood drift, Void moderation rumination, and the watchdog. Bifrost dispatch is present but disabled by default behind `BIFROST_DISPATCH_ENABLED=false`. +- The unattended repo Face / CTB swarm is live again as of 2026-05-21 after the teardown/restart pass. `GameCult Local Orchestrator` is enabled and owns the single hidden pulse for repo Face CTB turns, Void moderation heartbeat, Void mood drift, Void moderation rumination, and the watchdog. Bifrost dispatch is present but disabled by default behind `BIFROST_DISPATCH_ENABLED=false`. - The teardown authority remains `notes/agent-swarm-teardown-plan-2026-05-21.md`, but it is now a restart checklist and smell ledger rather than a stop sign. Keep using it when a Face/Bifrost/CTB boundary smells like prompt/parser/transport Jenga. - The repo-controlled pause flag is `state/agent-swarm-paused.json`. While it is missing or has `"paused": false`, swarm runners may operate. While it has `"paused": true` or is malformed, the Face heartbeat runner skips and the orchestrator skips agent-swarm organs. This is the brake. Do not route around it. - Postgres owns jobs, audit events, interaction memory, and rate-limit state. @@ -37,7 +37,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Prompt-facing rumination chronology is projected by `scripts/lib/void-rumination-context-projection.ps1`. Exact timestamps stay in typed state/status/cursors; the child context gets relative phrases like `5 minutes ago`, including recent Discord messages, repo activity, receipts, memory timestamps, and sleep/speech timing. - `scripts/simulate-void-mood.mjs` owns typed sleep/speaking-pressure over `.voidbot/private/void-self-state.cc`. During sleep, it invokes typed memory maintenance once per nap unless that nap already completed a pass; use `--skip-memory-maintenance` for smoke runs that must not call Codex. - `npm run moderation:memory-maintenance:sleep-fixture` now proves the non-skip sleep path without spending a live model call: it seeds a temporary `.cc`, forces mood drift through sleep maintenance, uses a fake Codex child to propose `apply_memory_distillation`, and verifies short-term residue is gone while the durable memory keeps subject, claim, tension, action implication, and anchor. -- The live local pulse is now one hidden scheduled task: `GameCult Local Orchestrator`. It invokes repo Face CTB heartbeats, Void mood drift, Void moderation rumination, and the operations watchdog with one lock, one status surface, per-organ logs, and per-organ timeouts. The Bifrost dispatch organ remains wired but is skipped unless `BIFROST_DISPATCH_ENABLED=true`. The old per-organ tasks are disabled and should remain disabled unless testing one organ in isolation. +- The live local pulse is now one hidden scheduled task: `GameCult Local Orchestrator`. It invokes repo Face CTB heartbeats, Void moderation heartbeat, Void mood drift, Void moderation rumination, and the operations watchdog with one lock, one status surface, per-organ logs, and per-organ timeouts. The Bifrost dispatch organ remains wired but is skipped unless `BIFROST_DISPATCH_ENABLED=true`. The old per-organ tasks are disabled and should remain disabled unless testing one organ in isolation. - `scripts/start-voidbot-stack.ps1` now treats the local Bifrost container stack as a startup dependency. It reads `BIFROST_ROOT` and `BIFROST_HEALTH_URL`, runs `E:\Projects\Bifrost\compose.local.yaml` when the health endpoint is down, and records the result under `runtime-stack.json` as `bifrost`. - `npm run moderation:rumination:fixture` proves the non-skip typed rumination branch without spending a live model call: it seeds a temporary `.cc`, routes the runner through a fake Codex child, and verifies short-term memory, incubation, agency pressure, and candidate intervention proposals are parent-applied typed operations with no exact timestamp leakage in the prompt-facing context. - `npm run moderation:rumination:speech-fixture` proves queued speech has a parent-owned action loop: fake Codex queues one candidate with a delivery target, fake Discord send writes a receipt, and the parent runner marks the candidate spoken through typed state. The child prompt now forbids direct send-script calls. @@ -51,8 +51,8 @@ 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. -- Urgent safety handling can now enforce through a parent-owned Discord action path. `VOID_MODERATION_ENFORCEMENT_MODE=log_only` is the non-destructive default, `notify_owner` sends an owner DM, and `timeout` calls `scripts/moderate-discord-user.mjs` to apply a Discord member timeout for the first non-owner urgent witness author after the model/open-case operation passes validation. The child prompt still does not own raw sanction authority. +- Severe safety evidence now has the same no-silent-advance wall. The moderation heartbeat 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` still covers the older rumination guard, while `npm run moderation:heartbeat:policy-fixture` proves the heartbeat-only policy path. +- Urgent safety handling can now enforce through a parent-owned Discord action path. `VOID_MODERATION_ENFORCEMENT_MODE=policy` enables instant bans and three-strike bans according to the explicit table in `config/discord-server-rules.md`; the child only opens typed cases with `infringement:` plus one moderation disposition tag. The child prompt still does not own raw sanction authority. - 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. @@ -74,7 +74,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Source repo discovery now reconciles automatically during `npm run stack:start` and through the watchdog path, so new repos under `SOURCE_REPO_ROOT` stop waiting for manual hook folklore before they get indexed. - Bot-directed prompts now stay in the raw Discord archive but are excluded from default semantic history indexing and lexical fallback, so repeated summons stop teaching the bot its own call-and-response loops. - Source-grounding hints are advisory now; the owner and local lanes can decide when repo or lore tool use is actually warranted instead of obeying a lexical front-door veto. -- The moderation rumination organ is invoked by `GameCult Local Orchestrator` against `.voidbot/private/void-self-state.cc` without reviving the legacy projection path. The prompt lives in `prompts/void-moderator-rumination.md`; treat that file as a sensitive behavior surface, not incidental script text. +- The moderation heartbeat organ is invoked every orchestrator pulse against `.voidbot/private/void-self-state.cc` with `prompts/void-moderation-heartbeat.md`, which exposes only rules, recent history, urgent witnesses, and infringement history. The moderation rumination organ is still invoked by `GameCult Local Orchestrator` for Void's slower person-shaped participation and possible announcement turns; its prompt lives in `prompts/void-moderator-rumination.md`. Treat both files as sensitive behavior surfaces, not incidental script text. - The moderation loop now uses the typed context packet from `scripts/run-void-moderator-rumination.ps1`; legacy Ghostlight-shaped JSON runtime fields are not the active memory surface. - Repo-weather sweeps are now incremental rather than a rolling four-day scrapbook. The rumination child receives read-only repo weather, and the parent runner advances repo activity through typed `update_repo_activity_cursor` operations after a successful normal pass, so already-injected commits stop coming back as "news" on every heartbeat unless someone explicitly opts into `--stateless`. - Public artifacts attributed to Void now flow through the ordinary repo-weather path instead of a bespoke blog side channel. `repoActivity.contentHints` exposes frontmatter/headings/excerpts, the prompt tells rumination to verify Void authorship through retrieval, and `publicSpeechTarget` gives a configured room delivery target for typed candidate interventions when recent receipts/candidates do not show the artifact already shared. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 0acceb5..c3f9a4c 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -140,16 +140,20 @@ Verse-facing service contract: `docs/architecture/voidbot-verse-service-contract - parent-owned speech fixture. It queues one deliverable candidate through fake Codex, routes delivery through a fake Discord sender, then verifies the parent runner writes a delivery receipt and marks the candidate spoken through typed state. - `scripts/smoke-void-rumination-nap-skip-fixture.ps1` - nap-skip fixture. It seeds a napping typed state with no room debt, points `CODEX_EXECUTABLE` at a bogus command, then verifies the runner exits with `napping_without_room_debt` and writes no operation proposals before invoking the model. +- `scripts/smoke-void-moderation-heartbeat-policy-fixture.ps1` + - rules-only moderation heartbeat fixture. It routes the heartbeat through a fake Codex child, verifies the prompt excludes rumination/memory/repo surfaces, writes a tagged instant-ban infringement case, and proves parent policy enforcement would call a Discord ban in dry-run mode. - `scripts/install-moderation-rumination-task.ps1` - legacy installer for isolating the moderation/participation loop. The live scheduled pulse is now `GameCult Local Orchestrator`; keep this per-organ task disabled unless testing the organ in isolation. - `scripts/run-gamecult-orchestrator.ps1` - - local pulse owner for agent transport/runtime chores. It runs Bifrost dispatch, repo Face CTB heartbeats, Void mood drift, Void moderation rumination, and the operations watchdog from one hidden scheduled task with one lock, per-organ status/log files, and per-organ timeouts. + - local pulse owner for agent transport/runtime chores. It runs Bifrost dispatch, repo Face CTB heartbeats, Void moderation heartbeat, Void mood drift, Void moderation rumination, and the operations watchdog from one hidden scheduled task with one lock, per-organ status/log files, and per-organ timeouts. - `scripts/install-gamecult-orchestrator-task.ps1` - installs scheduled task `GameCult Local Orchestrator` through the hidden PowerShell launcher and can disable the old per-organ tasks: `Bifrost Agent Dispatch`, `VoidBot Repo Face Heartbeats`, `Void Mood Drift`, `Void Moderator Rumination`, and `VoidBot Operations Watchdog`. - Scheduled organ `Void Mood Drift` - typed mood/sleep runner invoked by the orchestrator. It calls `scripts/simulate-void-mood.mjs`; when the typed sleep cycle is napping, that path invokes memory maintenance once per nap. +- Scheduled organ `Void Moderation Heartbeat` + - rules-only moderation loop invoked every orchestrator pulse. It uses `prompts/void-moderation-heartbeat.md`, recent Discord history, urgent witnesses, and typed infringement/open-case history; it may emit only `upsert_open_case` or `close_open_case`, then parent enforcement applies the explicit instaban/three-strike policy through `scripts/enforce-discord-moderation-policy.mjs`. - Scheduled organ `Void Moderator Rumination` - - typed-only moderation/participation runner invoked by the orchestrator; it has model-branch plus parent-owned speech fixtures. + - typed-only participation/announcement runner invoked by the orchestrator or CTB queue; it has model-branch plus parent-owned speech fixtures. It is no longer the every-heartbeat rules authority. - Live sleep maintenance - passed once on real state after a manual typed nap. The maintenance runner consumed the scheduled rumination short-term memory with one `apply_memory_distillation` operation, leaving short-term memory empty and preserving one durable identity seam with target, claim, tension, action implication, and anchors. - Sleep brake diff --git a/package.json b/package.json index 76e4cc5..fa6bdc0 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "rag:backfill": "tsx scripts/backfill-discord-logs.ts", "moderation:recent-history": "node scripts/export-recent-discord-history.mjs", "moderation:discord-action": "node scripts/moderate-discord-user.mjs", + "moderation:policy-enforce": "node scripts/enforce-discord-moderation-policy.mjs", "moderation:purge-context": "tsx scripts/purge-discord-context.ts", "agents:feed-consensus": "node scripts/feed-codex-chat-consensus.mjs", "moderation:random-history": "node scripts/export-random-discord-history.mjs", @@ -65,6 +66,7 @@ "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:heartbeat:policy-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-moderation-heartbeat-policy-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-moderation-heartbeat.md b/prompts/void-moderation-heartbeat.md new file mode 100644 index 0000000..2a62612 --- /dev/null +++ b/prompts/void-moderation-heartbeat.md @@ -0,0 +1,78 @@ +# Void Moderation Heartbeat + +Run one rules-only Discord moderation heartbeat. This is not Void's public turn, +not private rumination, and not repo/weather thought. The job is infringement +review against the server rules plus existing infringement history. + +## Required Reading + +- `{{CONTEXT_PATH}}` +- `config/discord-server-rules.md` + +## State Boundary + +- The canonical private self-state is `{{STATE_FILE_PATH}}`. +- The context file contains recent Discord history, current open/closed + moderation cases, urgent witnesses, and cursor context. +- Do not read or write legacy moderation projection files. +- Do not edit tracked repo files. +- Do not call Discord send or moderation scripts. +- Write proposed state changes to `{{OPERATION_OUTPUT_PATH}}` as a JSON array of + typed operation payloads for `scripts/void-self-state.mjs apply-operation`. + +## Authority + +The heartbeat owns infringement detection. The parent runner owns cursor +advancement and sanctions. Void's normal turn may later announce an action via +ordinary candidate speech, but this heartbeat does not speak in-channel. + +## Classification + +For each new infringement, emit one `upsert_open_case` operation with tags that +include exactly one `infringement:` from `config/discord-server-rules.md` +and exactly one of: + +- `moderation:instaban` +- `moderation:strike` +- `moderation:case_only` + +Use `moderation:instaban` only when the policy table's instant-ban condition is +met by the evidence. Use `moderation:strike` when the strike condition is met. +Use `moderation:case_only` only when evidence deserves a tracked case but does +not yet meet a sanction threshold. + +For urgent safety witnesses, do not write `[]` unless an existing case already +accounts for the same source message. Create the strongest case first. + +## Allowed Operations + +- `upsert_open_case` +- `close_open_case` + +No memory operations. No candidate interventions. No speech pressure operations. + +## Case Requirements + +Each `upsert_open_case.case` must include: + +- `sourceMessageId` +- `status`: usually `pending` for new sanctionable cases, `watching` for + case-only monitoring +- `summary`: concrete evidence, not a rule name +- `authorId` and `authorName` when available +- `channelId` +- `messageUrl` when available +- `whyItMatters`: why this violates the specific rule/policy +- `createdAt` and `lastTouchedAt`: exact timestamps from the message when + available, otherwise fresh exact timestamps +- `tags`: include the required `infringement:*` and `moderation:*` tags, plus + any useful evidence tags such as `safety:urgent` + +Do not multiply cases for one message. If several rules seem relevant, choose +the strongest supported infringement type and mention secondary context in the +summary. + +## Completion + +At the end, print a short run summary. The file at `{{OPERATION_OUTPUT_PATH}}` +is the contract; stdout is just trace. diff --git a/scripts/enforce-discord-moderation-policy.mjs b/scripts/enforce-discord-moderation-policy.mjs new file mode 100644 index 0000000..6d218d7 --- /dev/null +++ b/scripts/enforce-discord-moderation-policy.mjs @@ -0,0 +1,375 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { TextDecoder } from "node:util"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const POLICY = { + safety_threat: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true }, + weaponized_intimidation: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true }, + stalking_or_doxxing: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true }, + sexual_boundary_violation: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true }, + bigotry_identity_attack: { strikeExpiresDays: 180, instantBanTags: ["severity:instaban"], threeStrike: true }, + bad_faith_argument: { strikeExpiresDays: 90, instantBanTags: ["severity:instaban"], threeStrike: true }, + nsfw_channel_violation: { strikeExpiresDays: 30, instantBanTags: ["severity:instaban"], threeStrike: true }, + spam_or_deceptive_promotion: { strikeExpiresDays: 30, instantBanTags: ["severity:instaban"], threeStrike: true }, + moderator_obstruction: { strikeExpiresDays: 90, instantBanTags: ["severity:instaban"], threeStrike: true }, + empty_words_noise: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true }, + values_debate_escalation: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true }, + pg13_language_violation: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true }, + event_time_coordination: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true }, +}; + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const mode = normalizeMode(options.mode ?? readLocalEnv().VOID_MODERATION_ENFORCEMENT_MODE ?? "log_only"); + const operations = await readOperations(options.operationsFile); + const newCases = operations + .filter((operation) => operation?.operation === "upsert_open_case" && operation.case) + .map((operation) => operation.case); + + if (newCases.length === 0) { + writeJson({ ok: true, mode, status: "no_new_cases", actions: [] }); + return; + } + + const state = await loadTypedState(options.stateFile); + const actions = []; + for (const moderationCase of newCases) { + const action = await evaluateCase({ moderationCase, state, mode, dryRun: options.dryRun }); + if (action) { + actions.push(action); + } + } + + writeJson({ + ok: actions.every((action) => action.ok !== false), + mode, + status: actions.length > 0 ? "evaluated" : "no_policy_action", + actions, + }); +} + +function parseArgs(args) { + const options = { + stateFile: undefined, + operationsFile: undefined, + mode: undefined, + dryRun: false, + }; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + switch (argument) { + case "--state-file": + options.stateFile = args[index + 1]; + index += 1; + break; + case "--operations-file": + options.operationsFile = args[index + 1]; + index += 1; + break; + case "--mode": + options.mode = args[index + 1]; + index += 1; + break; + case "--dry-run": + options.dryRun = true; + break; + default: + throw new Error(`Unknown argument: ${argument}`); + } + } + if (!options.stateFile) { + throw new Error("Provide --state-file."); + } + if (!options.operationsFile) { + throw new Error("Provide --operations-file."); + } + return options; +} + +async function evaluateCase({ moderationCase, state, mode, dryRun }) { + const tags = new Set((moderationCase.tags ?? []).map((tag) => String(tag).toLowerCase())); + const infringementType = getTagValue(tags, "infringement:"); + if (!infringementType || !POLICY[infringementType]) { + return { + ok: true, + status: "skipped", + reason: "missing_or_unknown_infringement_type", + sourceMessageId: moderationCase.sourceMessageId, + tags: [...tags], + }; + } + + if (!moderationCase.authorId) { + return { + ok: true, + status: "skipped", + reason: "missing_author_id", + sourceMessageId: moderationCase.sourceMessageId, + infringementType, + }; + } + + if (hasActionedTag(tags)) { + return { + ok: true, + status: "skipped", + reason: "already_actioned", + sourceMessageId: moderationCase.sourceMessageId, + infringementType, + }; + } + + const policy = POLICY[infringementType]; + const isInstantBan = tags.has("moderation:instaban") || + policy.instantBanTags.some((tag) => tags.has(tag)); + const strikeCount = countActiveStrikes({ + cases: state.moderationCursor?.openCases ?? [], + authorId: moderationCase.authorId, + infringementType, + expiresDays: policy.strikeExpiresDays, + now: new Date(), + }); + const isThirdStrike = tags.has("moderation:strike") && policy.threeStrike && strikeCount >= 3; + + if (isInstantBan || isThirdStrike) { + const reason = isInstantBan + ? `VoidBot instaban: ${infringementType} at ${moderationCase.sourceMessageId}` + : `VoidBot three-strike ban: ${infringementType} strike ${strikeCount}/3 at ${moderationCase.sourceMessageId}`; + const moderationResult = await invokeModerationAction({ + action: "ban", + userId: moderationCase.authorId, + reason, + mode, + dryRun, + }); + await closeCase({ + stateFile: state.__stateFile, + sourceMessageId: moderationCase.sourceMessageId, + status: "resolved", + resolutionSummary: `${isInstantBan ? "Instaban" : "Three-strike ban"} applied for ${infringementType}.`, + dryRun, + }); + return { + ok: true, + status: isInstantBan ? "instaban_applied" : "three_strike_ban_applied", + sourceMessageId: moderationCase.sourceMessageId, + authorId: moderationCase.authorId, + infringementType, + strikeCount, + strikeExpiresDays: policy.strikeExpiresDays, + moderationResult, + }; + } + + if (tags.has("moderation:strike")) { + await closeCase({ + stateFile: state.__stateFile, + sourceMessageId: moderationCase.sourceMessageId, + status: "resolved", + resolutionSummary: `Strike ${strikeCount}/3 recorded for ${infringementType}; expires after ${policy.strikeExpiresDays} days without another matching strike.`, + dryRun, + }); + return { + ok: true, + status: "strike_recorded", + sourceMessageId: moderationCase.sourceMessageId, + authorId: moderationCase.authorId, + infringementType, + strikeCount, + strikeExpiresDays: policy.strikeExpiresDays, + }; + } + + return { + ok: true, + status: "case_recorded_no_sanction", + sourceMessageId: moderationCase.sourceMessageId, + authorId: moderationCase.authorId, + infringementType, + strikeCount, + strikeExpiresDays: policy.strikeExpiresDays, + }; +} + +function countActiveStrikes({ cases, authorId, infringementType, expiresDays, now }) { + const cutoffMs = now.getTime() - expiresDays * 24 * 60 * 60 * 1000; + return cases.filter((entry) => { + if (entry.authorId !== authorId) { + return false; + } + const tags = new Set((entry.tags ?? []).map((tag) => String(tag).toLowerCase())); + if (!tags.has(`infringement:${infringementType}`) || !tags.has("moderation:strike")) { + return false; + } + const timestampMs = Date.parse(entry.createdAt ?? entry.lastTouchedAt ?? ""); + return Number.isFinite(timestampMs) && timestampMs >= cutoffMs; + }).length; +} + +async function invokeModerationAction({ action, userId, reason, mode, dryRun }) { + if (["off", "disabled", "log_only", "log-only", "case_only", "case-only"].includes(mode)) { + return { ok: true, status: "skipped", reason: "enforcement_mode_non_destructive", action, userId }; + } + if (mode === "notify_owner") { + return { ok: true, status: "skipped", reason: "notify_owner_does_not_apply_discord_sanctions", action, userId }; + } + if (!["policy", "enforce_policy", "enforce-policy", "ban"].includes(mode)) { + return { ok: true, status: "skipped", reason: `mode_${mode}_does_not_apply_policy_bans`, action, userId }; + } + + const args = [ + resolve(repoRoot, "scripts", "moderate-discord-user.mjs"), + "--action", action, + "--user-id", userId, + "--reason", reason, + ]; + if (dryRun) { + args.push("--dry-run"); + } + const result = spawnSync(process.execPath, args, { + cwd: repoRoot, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error(`Discord moderation action failed: ${result.stderr || result.stdout}`); + } + return JSON.parse(result.stdout); +} + +async function closeCase({ stateFile, sourceMessageId, status, resolutionSummary, dryRun }) { + if (dryRun) { + return; + } + const tempRoot = mkdtempSync(resolve(tmpdir(), "void-policy-close-")); + const operationPath = resolve(tempRoot, "operation.json"); + try { + writeFileSync(operationPath, `${JSON.stringify({ + operation: "close_open_case", + sourceMessageId, + status, + resolvedAt: new Date().toISOString(), + resolutionSummary, + }, null, 2)}\n`, "utf8"); + const result = spawnSync(process.execPath, [ + resolve(repoRoot, "scripts", "void-self-state.mjs"), + "apply-operation", + "--canonical", stateFile, + "--operation-file", operationPath, + ], { + cwd: repoRoot, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error(`Failed to close moderation case: ${result.stderr || result.stdout}`); + } + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function normalizeMode(value) { + return String(value).trim().toLowerCase(); +} + +function getTagValue(tags, prefix) { + for (const tag of tags) { + if (tag.startsWith(prefix)) { + return tag.slice(prefix.length); + } + } + return undefined; +} + +function hasActionedTag(tags) { + for (const tag of tags) { + if (tag.startsWith("moderation:actioned")) { + return true; + } + } + return false; +} + +async function readOperations(path) { + const raw = await readFile(path, "utf8"); + if (!raw.trim()) { + return []; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; +} + +async function loadTypedState(stateFile) { + const script = ` +const core = require('./packages/core/dist'); +core.loadVoidSelfStateTypedDocuments({ canonicalPath: process.argv[1] }) + .then((state) => console.log(JSON.stringify(state))) + .catch((error) => { console.error(error); process.exit(1); }); +`; + const result = spawnSync(process.execPath, ["-e", script, stateFile], { + cwd: repoRoot, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error(`Failed to load typed state: ${result.stderr || result.stdout}`); + } + return { ...JSON.parse(result.stdout), __stateFile: stateFile }; +} + +function readLocalEnv() { + const envPath = resolve(repoRoot, ".env"); + const parsed = { ...process.env }; + try { + const raw = stripLeadingBom(readTextFileFlexible(envPath)); + Object.assign(parsed, parseDotEnv(raw)); + } catch (error) { + if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) { + throw error; + } + } + return parsed; +} + +function parseDotEnv(raw) { + const result = {}; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + const separatorIndex = trimmed.indexOf("="); + if (separatorIndex === -1) { + continue; + } + result[trimmed.slice(0, separatorIndex).trim()] = trimmed.slice(separatorIndex + 1).trim(); + } + return result; +} + +function readTextFileFlexible(path) { + const buffer = readFileSync(path); + if (buffer.length >= 2) { + if (buffer[0] === 0xff && buffer[1] === 0xfe) { + return new TextDecoder("utf-16le").decode(buffer); + } + if (buffer[0] === 0xfe && buffer[1] === 0xff) { + return new TextDecoder("utf-16be").decode(buffer); + } + } + return new TextDecoder("utf-8").decode(buffer); +} + +function stripLeadingBom(value) { + return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value; +} + +function writeJson(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +await main(); diff --git a/scripts/run-gamecult-orchestrator.ps1 b/scripts/run-gamecult-orchestrator.ps1 index 3d173c0..10651f3 100644 --- a/scripts/run-gamecult-orchestrator.ps1 +++ b/scripts/run-gamecult-orchestrator.ps1 @@ -311,6 +311,15 @@ try { Executable = $powershell Arguments = @("-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", (Join-Path $PSScriptRoot "run-void-mood-drift.ps1")) }, + [pscustomobject]@{ + Id = "void-moderation-heartbeat" + Label = "Void rules moderation heartbeat" + IntervalMinutes = Get-ConfigInt -Config $config -Name "VOIDBOT_MODERATION_HEARTBEAT_INTERVAL_MINUTES" -Default 1 -Minimum 1 + TimeoutMinutes = 10 + Cwd = $repoRoot + Executable = $powershell + Arguments = @("-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", (Join-Path $PSScriptRoot "run-void-moderator-rumination.ps1"), "-ModerationHeartbeatOnly") + }, [pscustomobject]@{ Id = "void-moderation-rumination" Label = "Void moderation rumination" diff --git a/scripts/run-void-moderator-rumination.ps1 b/scripts/run-void-moderator-rumination.ps1 index 3cf6b5c..9a833ff 100644 --- a/scripts/run-void-moderator-rumination.ps1 +++ b/scripts/run-void-moderator-rumination.ps1 @@ -1,7 +1,8 @@ param( [string] $StateFilePath, [switch] $NoPost, - [switch] $SkipModel + [switch] $SkipModel, + [switch] $ModerationHeartbeatOnly ) Set-StrictMode -Version Latest @@ -26,24 +27,30 @@ $logDir = if (-not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_LOG_DIR)) { } else { Join-Path $repoRoot ".voidbot\logs" } -$statusPath = Join-Path $statusDir "moderation-rumination.json" -$summaryLogPath = Join-Path $logDir "moderation-rumination.log" -$tracePath = Join-Path $logDir "moderation-rumination-last.jsonl" -$lastMessagePath = Join-Path $statusDir "moderation-rumination-last-message.txt" -$operationOutputPath = Join-Path $statusDir "moderation-rumination-operations.json" -$contextPath = Join-Path $statusDir "moderation-rumination-context.json" -$lockPath = Join-Path $statusDir "moderation-rumination.lock" +$runSlug = if ($ModerationHeartbeatOnly) { "moderation-heartbeat" } else { "moderation-rumination" } +$statusPath = Join-Path $statusDir "$runSlug.json" +$summaryLogPath = Join-Path $logDir "$runSlug.log" +$tracePath = Join-Path $logDir "$runSlug-last.jsonl" +$lastMessagePath = Join-Path $statusDir "$runSlug-last-message.txt" +$operationOutputPath = Join-Path $statusDir "$runSlug-operations.json" +$contextPath = Join-Path $statusDir "$runSlug-context.json" +$lockPath = Join-Path $statusDir "$runSlug.lock" $pendingMentionsPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_PENDING_MENTIONS_PATH)) { $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:VOID_RUMINATION_PENDING_MENTIONS_PATH) } else { Join-Path $statusDir "void-moderation-pending-mentions.json" } -$promptTemplatePath = Join-Path $repoRoot "prompts\void-moderator-rumination.md" +$promptTemplatePath = if ($ModerationHeartbeatOnly) { + Join-Path $repoRoot "prompts\void-moderation-heartbeat.md" +} else { + Join-Path $repoRoot "prompts\void-moderator-rumination.md" +} $contextProjectionScriptPath = Join-Path $repoRoot "scripts\lib\void-rumination-context-projection.ps1" $recentHistoryScriptPath = Join-Path $repoRoot "scripts\export-recent-discord-history.mjs" $repoActivityScriptPath = Join-Path $repoRoot "scripts\export-recent-repo-activity.mjs" $selfStateScriptPath = Join-Path $repoRoot "scripts\void-self-state.mjs" $moderationActionScriptPath = Join-Path $repoRoot "scripts\moderate-discord-user.mjs" +$moderationPolicyScriptPath = Join-Path $repoRoot "scripts\enforce-discord-moderation-policy.mjs" $sendMessageScriptPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_SEND_DISCORD_SCRIPT)) { $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:VOID_SEND_DISCORD_SCRIPT) } else { @@ -523,11 +530,11 @@ function Invoke-UrgentModerationEnforcement { } $normalizedMode = if ([string]::IsNullOrWhiteSpace($Mode)) { "log_only" } else { $Mode.Trim().ToLowerInvariant() } - if ($normalizedMode -in @("off", "disabled", "log_only", "log-only", "case_only", "case-only")) { + if ($normalizedMode -in @("off", "disabled", "log_only", "log-only", "case_only", "case-only", "policy", "enforce_policy", "enforce-policy", "ban")) { return @{ mode = $normalizedMode status = "skipped" - reason = "enforcement_mode_non_destructive" + reason = if ($normalizedMode -in @("policy", "enforce_policy", "enforce-policy", "ban")) { "policy_enforcement_owned_by_moderation_heartbeat" } else { "enforcement_mode_non_destructive" } witnessCount = [int]$Witnesses.Count } } @@ -582,7 +589,70 @@ function Invoke-UrgentModerationEnforcement { } } - throw "Unsupported VOID_MODERATION_ENFORCEMENT_MODE '$Mode'. Use log_only, notify_owner, or timeout." + throw "Unsupported VOID_MODERATION_ENFORCEMENT_MODE '$Mode'. Use log_only, notify_owner, timeout, or policy." +} + +function Assert-ModerationHeartbeatOperations { + param( + [object[]] $Operations, + $TypedState + ) + + foreach ($operation in $Operations) { + $operationName = Get-ObjectPropertyString -Value $operation -Name "operation" + if ($null -eq $operationName) { + continue + } + if ($operationName -notin @("upsert_open_case", "close_open_case")) { + throw "Moderation heartbeat may only emit upsert_open_case or close_open_case operations; got '$operationName'." + } + if ($operationName -eq "upsert_open_case") { + $case = Get-ObjectPropertyValue -Value $operation -Name "case" + $sourceMessageId = Get-ObjectPropertyString -Value $case -Name "sourceMessageId" + $priorCase = @( + @(Convert-ToValueArray -Value $TypedState.moderationCursor.openCases) | + Where-Object { (Get-ObjectPropertyString -Value $_ -Name "sourceMessageId") -eq $sourceMessageId } + ) | Select-Object -First 1 + if ($null -ne $priorCase) { + $priorStatus = Get-ObjectPropertyString -Value $priorCase -Name "status" + $priorResolution = Get-ObjectPropertyString -Value $priorCase -Name "resolutionSummary" + if ($priorStatus -in @("answered", "resolved", "closed", "retired", "dropped") -and $priorResolution -match "(?i)(instaban|three-strike ban|strike \d/3 recorded|ban applied)") { + throw "Moderation heartbeat cannot reopen already-actioned case '$sourceMessageId'." + } + } + $tags = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $case -Name "tags")) | ForEach-Object { ([string]$_).ToLowerInvariant() } + $infringementTags = @($tags | Where-Object { $_.StartsWith("infringement:") }) + $moderationTags = @($tags | Where-Object { $_ -in @("moderation:instaban", "moderation:strike", "moderation:case_only") }) + if ($infringementTags.Count -ne 1) { + throw "Moderation heartbeat open cases must include exactly one infringement: tag." + } + if ($moderationTags.Count -ne 1) { + throw "Moderation heartbeat open cases must include exactly one moderation:instaban, moderation:strike, or moderation:case_only tag." + } + } + } +} + +function Invoke-ModerationPolicyEnforcement { + param( + [string] $Mode, + [switch] $DryRun + ) + + $arguments = @( + $moderationPolicyScriptPath, + "--state-file", $stateFilePath, + "--operations-file", $operationOutputPath, + "--mode", $Mode + ) + if ($DryRun) { + $arguments += "--dry-run" + } + $output = & node @arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Moderation policy enforcement failed: $($output | Out-String)" + } + return $output | ConvertFrom-Json } function Get-TypedSelfState { @@ -1206,7 +1276,7 @@ if (Test-Path $operationOutputPath) { Remove-Item -LiteralPath $operationOutputPath -Force } -foreach ($requiredPath in @($contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath, $moderationActionScriptPath, $sendMessageScriptPath)) { +foreach ($requiredPath in @($promptTemplatePath, $contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath, $moderationActionScriptPath, $moderationPolicyScriptPath, $sendMessageScriptPath)) { if (-not (Test-Path $requiredPath)) { throw "Missing required helper at $requiredPath" } @@ -1262,6 +1332,7 @@ $moderationTimeoutMinutes = Get-ConfigValue -Name "VOID_MODERATION_TIMEOUT_MINUT if ([string]::IsNullOrWhiteSpace($moderationTimeoutMinutes)) { $moderationTimeoutMinutes = "60" } +$moderationPolicyDryRun = -not [string]::IsNullOrWhiteSpace($env:VOID_MODERATION_POLICY_DRY_RUN) $typedContext = Get-TypedSelfState $typedState = $typedContext.typedState @@ -1295,54 +1366,79 @@ if ($messageCount -gt 0) { } $repoActivity = $null -try { - $repoActivity = Invoke-NodeJson -Arguments @( - $repoActivityScriptPath, - "--hours", "96", - "--max-commits", "3", - "--read-only", - "--state-path", $stateFilePath - ) -} catch { +if ($ModerationHeartbeatOnly) { $repoActivity = @{ - status = "failed" - error = $_.Exception.Message + status = "skipped" + reason = "moderation_heartbeat_only" + } +} else { + try { + $repoActivity = Invoke-NodeJson -Arguments @( + $repoActivityScriptPath, + "--hours", "96", + "--max-commits", "3", + "--read-only", + "--state-path", $stateFilePath + ) + } catch { + $repoActivity = @{ + status = "failed" + error = $_.Exception.Message + } } } $pendingMentions = @() -$pendingMentionPacket = Read-JsonFile -Path $pendingMentionsPath -if ($null -ne $pendingMentionPacket) { - $pendingMentionsValue = Get-ObjectPropertyValue -Value $pendingMentionPacket -Name "pendingMentions" - if ($null -ne $pendingMentionsValue) { - $pendingMentions = @(Convert-ToValueArray -Value $pendingMentionsValue) +if (-not $ModerationHeartbeatOnly) { + $pendingMentionPacket = Read-JsonFile -Path $pendingMentionsPath + if ($null -ne $pendingMentionPacket) { + $pendingMentionsValue = Get-ObjectPropertyValue -Value $pendingMentionPacket -Name "pendingMentions" + if ($null -ne $pendingMentionsValue) { + $pendingMentions = @(Convert-ToValueArray -Value $pendingMentionsValue) + } } } -Write-JsonFile -Path $contextPath -Data @{ - generated = "now" - stateFile = $stateFilePath - noPost = [bool]$NoPost - selfStateSummary = $typedContext.summary - chronology = "Times in this prompt-facing context are relative phrases. Exact timestamps stay parent-owned for typed state and cursor bookkeeping." - openCases = @(Project-OpenCasesForRumination -Cases $typedState.moderationCursor.openCases -Now $startedAtUtc) - speechReceipts = @(Project-SpeechReceiptsForRumination -Receipts $typedState.speechReceipts.recentReceipts -Now $startedAtUtc) - memories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.memories -Now $startedAtUtc) - shortTermMemories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.shortTerm -Now $startedAtUtc) - 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 - deliverableCandidateCount = [int]$deliverableCandidates.Count - candidateInterventions = @(Project-InterventionsForRumination -Interventions $typedState.candidateInterventions.interventions -Now $startedAtUtc) - scheduledRuntime = Project-ScheduledRuntimeForRumination -Runtime $typedState.scheduledRuntime -Now $startedAtUtc - priorCursor = Project-CursorForRumination -Cursor $priorCursor -Now $startedAtUtc - observedCursor = Project-CursorForRumination -Cursor $observedCursor -Now $startedAtUtc - recentHistory = Project-RecentHistoryForRumination -History $history -Now $startedAtUtc - repoActivity = Select-RuminationRepoActivity -RepoActivity $repoActivity -Now $startedAtUtc +if ($ModerationHeartbeatOnly) { + Write-JsonFile -Path $contextPath -Data @{ + generated = "now" + mode = "moderation_heartbeat" + stateFile = $stateFilePath + noPost = [bool]$NoPost + chronology = "Times in this prompt-facing context are relative phrases. Exact timestamps stay parent-owned for typed state and cursor bookkeeping." + openCases = @(Project-OpenCasesForRumination -Cases $typedState.moderationCursor.openCases -Now $startedAtUtc) + urgentModerationWitnesses = $urgentModerationWitnesses + priorCursor = Project-CursorForRumination -Cursor $priorCursor -Now $startedAtUtc + observedCursor = Project-CursorForRumination -Cursor $observedCursor -Now $startedAtUtc + recentHistory = Project-RecentHistoryForRumination -History $history -Now $startedAtUtc + enforcementMode = $moderationEnforcementMode + } +} else { + Write-JsonFile -Path $contextPath -Data @{ + generated = "now" + stateFile = $stateFilePath + noPost = [bool]$NoPost + selfStateSummary = $typedContext.summary + chronology = "Times in this prompt-facing context are relative phrases. Exact timestamps stay parent-owned for typed state and cursor bookkeeping." + openCases = @(Project-OpenCasesForRumination -Cases $typedState.moderationCursor.openCases -Now $startedAtUtc) + speechReceipts = @(Project-SpeechReceiptsForRumination -Receipts $typedState.speechReceipts.recentReceipts -Now $startedAtUtc) + memories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.memories -Now $startedAtUtc) + shortTermMemories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.shortTerm -Now $startedAtUtc) + 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 + deliverableCandidateCount = [int]$deliverableCandidates.Count + candidateInterventions = @(Project-InterventionsForRumination -Interventions $typedState.candidateInterventions.interventions -Now $startedAtUtc) + scheduledRuntime = Project-ScheduledRuntimeForRumination -Runtime $typedState.scheduledRuntime -Now $startedAtUtc + priorCursor = Project-CursorForRumination -Cursor $priorCursor -Now $startedAtUtc + observedCursor = Project-CursorForRumination -Cursor $observedCursor -Now $startedAtUtc + recentHistory = Project-RecentHistoryForRumination -History $history -Now $startedAtUtc + repoActivity = Select-RuminationRepoActivity -RepoActivity $repoActivity -Now $startedAtUtc + } } $isNapping = [bool](Get-ObjectPropertyValue -Value $typedState.scheduledRuntime.sleepCycle -Name "isNapping") @@ -1350,7 +1446,7 @@ $openCaseCount = @( @(Convert-ToValueArray -Value $typedState.moderationCursor.openCases) | Where-Object { Test-OpenCaseRequiresRumination -Case $_ } ).Count -if ($isNapping -and $messageCount -eq 0 -and $openCaseCount -eq 0 -and $deliverableCandidates.Count -eq 0) { +if (-not $ModerationHeartbeatOnly -and $isNapping -and $messageCount -eq 0 -and $openCaseCount -eq 0 -and $deliverableCandidates.Count -eq 0) { Append-RunLog "napping: no new room messages or open cases; skipping awake rumination." Write-JsonFile -Path $operationOutputPath -Data @() $finishedAtUtc = [DateTime]::UtcNow @@ -1446,7 +1542,11 @@ $appliedOperations = @() $moderationEnforcementResult = $null Assert-UrgentModerationWitnessesHandled -Witnesses $urgentModerationWitnesses -Operations $proposedOperations -TypedState $typedState -Assert-SpeechPressureObligationsResolved -Obligations $speechPressureObligations -Operations $proposedOperations +if ($ModerationHeartbeatOnly) { + Assert-ModerationHeartbeatOperations -Operations $proposedOperations -TypedState $typedState +} else { + Assert-SpeechPressureObligationsResolved -Obligations $speechPressureObligations -Operations $proposedOperations +} Append-RunLog ("applying proposed operations: {0}" -f @($proposedOperations).Count) foreach ($operation in $proposedOperations) { @@ -1458,7 +1558,11 @@ foreach ($operation in $proposedOperations) { $appliedOperations += Apply-TypedOperation -Operation $operation } -if (-not $NoPost) { +if ($ModerationHeartbeatOnly) { + $moderationEnforcementResult = Invoke-ModerationPolicyEnforcement ` + -Mode $moderationEnforcementMode ` + -DryRun:([bool]$moderationPolicyDryRun) +} elseif (-not $NoPost) { $moderationEnforcementResult = Invoke-UrgentModerationEnforcement ` -Witnesses $urgentModerationWitnesses ` -Mode $moderationEnforcementMode ` @@ -1482,7 +1586,7 @@ if ( $repoCursorOperations = @() $disableRepoCursorAdvance = -not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE) -if (-not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) { +if (-not $ModerationHeartbeatOnly -and -not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) { $repoCursorOperations = @(Get-RepoActivityCursorOperations -RepoActivity $repoActivity -ObservedAt ([DateTime]::UtcNow)) if ($repoCursorOperations.Count -gt 0) { Append-RunLog ("recording repo activity cursors: {0}" -f $repoCursorOperations.Count) @@ -1493,7 +1597,7 @@ if (-not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) { } $deliveredCandidateCount = 0 -if (-not $NoPost) { +if (-not $ModerationHeartbeatOnly -and -not $NoPost) { $refreshedTypedContext = Get-TypedSelfState $alreadyAnsweredCandidateOperations = @(Get-AlreadyAnsweredCandidateRetireOperations -TypedState $refreshedTypedContext.typedState -RetiredAt ([DateTime]::UtcNow)) if ($alreadyAnsweredCandidateOperations.Count -gt 0) { @@ -1523,7 +1627,7 @@ $finishedAtUtc = [DateTime]::UtcNow Append-RunLog "final status timestamp captured." $lastMessageSummary = @( "Void moderation rumination finished." - "mode=" + $(if ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" }) + "mode=" + $(if ($ModerationHeartbeatOnly) { "moderation_heartbeat" } elseif ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" }) "proposed=" + [string]@($proposedOperations).Count "applied=" + [string]@($appliedOperations).Count "deliveredCandidates=" + [string]$deliveredCandidateCount @@ -1535,7 +1639,7 @@ Append-RunLog "final status last message loaded." $finalStatus = [ordered]@{ status = "ok" - mode = if ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" } + mode = if ($ModerationHeartbeatOnly) { "moderation_heartbeat" } elseif ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" } startedAt = $startedAtUtc.ToString("o") finishedAt = $finishedAtUtc.ToString("o") durationSeconds = [Math]::Round(($finishedAtUtc - $startedAtUtc).TotalSeconds, 2) @@ -1560,5 +1664,5 @@ $finalStatus = [ordered]@{ } Write-JsonFile -Path $statusPath -Data $finalStatus -Append-RunLog ("mode=typed_rumination messages={0} proposed={1} applied={2} cursor={3}" -f $messageCount, @($proposedOperations).Count, @($appliedOperations).Count, $observedCursorTimestamp) +Append-RunLog ("mode={0} messages={1} proposed={2} applied={3} cursor={4}" -f $(if ($ModerationHeartbeatOnly) { "moderation_heartbeat" } else { "typed_rumination" }), $messageCount, @($proposedOperations).Count, @($appliedOperations).Count, $observedCursorTimestamp) Remove-Item -Path $lockPath -Force -ErrorAction SilentlyContinue diff --git a/scripts/smoke-void-moderation-heartbeat-policy-fixture.ps1 b/scripts/smoke-void-moderation-heartbeat-policy-fixture.ps1 new file mode 100644 index 0000000..f01bbd1 --- /dev/null +++ b/scripts/smoke-void-moderation-heartbeat-policy-fixture.ps1 @@ -0,0 +1,163 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location -LiteralPath $repoRoot + +$tempRoot = Join-Path $env:TEMP ("void-moderation-heartbeat-policy-fixture-" + [guid]::NewGuid().ToString("n")) +$fakeCodexPath = Join-Path $tempRoot "fake-codex-heartbeat-policy.mjs" + +New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null + +function Set-TestEnvironment { + param( + [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:previousFixtureOutput = $env:VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT + $script:previousStatusDir = $env:VOID_RUMINATION_STATUS_DIR + $script:previousLogDir = $env:VOID_RUMINATION_LOG_DIR + $script:previousPolicyDryRun = $env:VOID_MODERATION_POLICY_DRY_RUN + $script:previousPolicyMode = $env:VOID_MODERATION_ENFORCEMENT_MODE + + $env:CODEX_EXECUTABLE = "node" + $env:CODEX_EXEC_ARGS = $fakeCodexPath + $env:VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT = $OperationOutputPath + $env:VOID_RUMINATION_STATUS_DIR = $StatusDir + $env:VOID_RUMINATION_LOG_DIR = $LogDir + $env:VOID_MODERATION_POLICY_DRY_RUN = "1" + $env:VOID_MODERATION_ENFORCEMENT_MODE = "policy" +} + +function Restore-TestEnvironment { + $env:CODEX_EXECUTABLE = $script:previousCodexExecutable + $env:CODEX_EXEC_ARGS = $script:previousCodexExecArgs + $env:VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT = $script:previousFixtureOutput + $env:VOID_RUMINATION_STATUS_DIR = $script:previousStatusDir + $env:VOID_RUMINATION_LOG_DIR = $script:previousLogDir + $env:VOID_MODERATION_POLICY_DRY_RUN = $script:previousPolicyDryRun + $env:VOID_MODERATION_ENFORCEMENT_MODE = $script:previousPolicyMode +} + +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_HEARTBEAT_FIXTURE_OPERATION_OUTPUT); +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", () => { + for (const required of ["Void Moderation Heartbeat", "config/discord-server-rules.md", "infringement:"]) { + if (!prompt.includes(required)) { + console.error(`heartbeat prompt missing ${required}`); + process.exit(2); + } + } + for (const forbidden of ["repoActivity", "shortTermMemories", "candidateInterventions", "styles/void-default.md"]) { + if (prompt.includes(forbidden)) { + console.error(`heartbeat prompt included forbidden rumination surface ${forbidden}`); + process.exit(3); + } + } + + const operations = [{ + 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: "The policy treats credible threats and weaponized declarations of violent conflict as instant-ban safety violations.", + createdAt: "2026-06-08T12:10:15.752Z", + lastTouchedAt: "2026-06-08T12:10:15.752Z", + tags: [ + "safety:urgent", + "infringement:safety_threat", + "moderation:instaban", + "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 heartbeat policy\n", "utf8"); + } + + process.stdout.write(JSON.stringify({ type: "fixture", operationCount: operations.length }) + "\n"); +}); +'@ + [System.IO.File]::WriteAllText($fakeCodexPath, $fakeCodexSource, [System.Text.UTF8Encoding]::new($false)) + + $fixtureRoot = Join-Path $tempRoot "handled" + $stateFilePath = Join-Path $fixtureRoot "void-self-state.cc" + $statusDir = Join-Path $fixtureRoot "status" + $logDir = Join-Path $fixtureRoot "logs" + New-Item -ItemType Directory -Force -Path $fixtureRoot | Out-Null + New-SeededState -StateFilePath $stateFilePath + + try { + Set-TestEnvironment -OperationOutputPath (Join-Path $statusDir "moderation-heartbeat-operations.json") -StatusDir $statusDir -LogDir $logDir + powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-void-moderator-rumination.ps1 -StateFilePath $stateFilePath -ModerationHeartbeatOnly -NoPost | Out-Null + } finally { + Restore-TestEnvironment + } + + $status = Get-Content -LiteralPath (Join-Path $statusDir "moderation-heartbeat.json") -Raw -Encoding UTF8 | ConvertFrom-Json + if ($status.status -ne "ok" -or $status.mode -ne "moderation_heartbeat") { + throw "Heartbeat policy fixture did not finish in moderation_heartbeat mode." + } + $action = @($status.moderationEnforcement.actions | Where-Object { $_.status -eq "instaban_applied" }) | Select-Object -First 1 + if ($null -eq $action -or $action.moderationResult.dryRun -ne $true -or $action.moderationResult.action -ne "ban") { + throw "Heartbeat policy fixture did not produce a dry-run instaban action." + } + + $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); })" $stateFilePath + $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("infringement:safety_threat")) { + throw "Heartbeat policy fixture did not persist the tagged infringement case." + } + + @{ + status = "ok" + mode = $status.mode + enforcementStatus = $action.status + action = $action.moderationResult.action + dryRun = $action.moderationResult.dryRun + } | 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 4e26fb9..204676d 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -333,3 +333,4 @@ {"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."} {"ts":"2026-06-08T21:51:40Z","type":"implementation","status":"ok","note":"Added parent-owned Discord moderation enforcement for urgent safety witnesses: runner records/validates the urgent case first, then can log only, notify owner, or apply a Discord timeout via scripts/moderate-discord-user.mjs according to VOID_MODERATION_ENFORCEMENT_MODE."} +{"ts":"2026-06-08T22:28:42Z","type":"implementation","status":"ok","note":"Split moderation authority into an every-pulse rules-only heartbeat and slower Void rumination. The heartbeat uses explicit infringement tags plus config/discord-server-rules.md instaban/three-strike expiry policy, and parent enforcement applies policy bans through scripts/enforce-discord-moderation-policy.mjs."} diff --git a/state/map.yaml b/state/map.yaml index 04bf26a..297b699 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -61,7 +61,7 @@ active_subgoals: - Treat `notes/agent-swarm-teardown-plan-2026-05-21.md` as the historical teardown authority and the restart checklist. The repo Face swarm is live again, but only because the scheduler, prompt, model-output logs, parent Interpreter, Bifrost routing, and worker side-effect rails now have explicit observable boundaries. Keep watching for prompt/parser/transport Jenga before adding new swarm features. - Make the new project-state surfaces the default continuity path for nontrivial VoidBot work. - Keep the rebuilt moderation/self-state foundation on canonical CultCache `.cc` typed operations, never an editable JSON working projection. - - Keep the typed moderation, mood, repo Face, and watchdog organs behind the single `GameCult Local Orchestrator` scheduled pulse; the per-organ scheduled tasks are legacy installer surfaces and should stay disabled unless a deliberate isolation test needs one. Bifrost dispatch is disabled by default behind `BIFROST_DISPATCH_ENABLED=false` until the governance bridge is rebuilt cleanly. As of the 2026-05-21 restart, the orchestrator task is enabled and is the live swarm pulse owner. + - Keep the typed moderation heartbeat, Void rumination, mood, repo Face, and watchdog organs behind the single `GameCult Local Orchestrator` scheduled pulse; the per-organ scheduled tasks are legacy installer surfaces and should stay disabled unless a deliberate isolation test needs one. Bifrost dispatch is disabled by default behind `BIFROST_DISPATCH_ENABLED=false` until the governance bridge is rebuilt cleanly. As of the 2026-05-21 restart, the orchestrator task is enabled and is the live swarm pulse owner. - Keep legacy moderation mirrors, whole-state projection commits, and deleted memory-organ compensators from re-entering the active path before adding more moderation, mood, agency, or public-lane behavior. - Keep public-lane limits, moderation, and provider behavior constrained enough that one 4060 Ti box does not get flayed for sport. - Preserve boring observability and startup discipline around Postgres, Qdrant, Ollama, Docker, backups, scheduled tasks, watchdogs, and any private local fire-signal extensions. @@ -181,8 +181,8 @@ 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. - - Urgent safety handling now has a parent-owned Discord action path. The child model may account for the witness by opening moderation state, but sanctions stay behind `VOID_MODERATION_ENFORCEMENT_MODE`: `log_only` records the case without acting, `notify_owner` sends an owner DM, and `timeout` calls `scripts/moderate-discord-user.mjs` to apply a Discord member timeout before cursor advancement. This is the narrow moderation actuator; do not give the child prompt raw ban/kick/timeout authority. + - Severe safety evidence is no longer allowed to vanish into cursor advancement. The moderation heartbeat 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. + - Urgent safety handling now has a parent-owned Discord action path and explicit infringement policy. The child model may account for a witness by opening typed moderation state with exactly one `infringement:` tag and exactly one `moderation:instaban`, `moderation:strike`, or `moderation:case_only` tag; sanctions stay behind `VOID_MODERATION_ENFORCEMENT_MODE=policy`, where parent enforcement applies instant bans or three-strike bans according to `config/discord-server-rules.md`. Do not give the child prompt raw ban/kick/timeout authority. - 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. @@ -217,7 +217,7 @@ landed_baseline: - State storage is now split into a thin factory plus per-domain Postgres/file store modules and bootstrap/migration helpers instead of one persistence omnibus. - Source archive storage is now sharded by repo behind a manifest plus per-repo shard directory instead of one giant `source-documents.json` blob. - Source repo indexing now reconciles automatically during stack startup and watchdog runs, refreshing hooks, indexing newly discovered repos, and pruning stale repo shards when repos disappear from `SOURCE_REPO_ROOT`. - - The typed `Void Moderator Rumination` and `Void Mood Drift` organs now run through the single `GameCult Local Orchestrator` scheduled task. Moderation builds a bounded context packet with prompt-facing relative chronology, runs the reviewed rumination prompt from `prompts/void-moderator-rumination.md`, applies typed operation payloads, and skips during naps when there is no room debt; mood updates typed sleep/speaking pressure and invokes memory maintenance during sleep. + - The typed `Void Moderation Heartbeat`, `Void Moderator Rumination`, and `Void Mood Drift` organs now run through the single `GameCult Local Orchestrator` scheduled task. The heartbeat runs every pulse with only rules, recent history, urgent witnesses, and infringement history; it applies typed case operations and parent-owned policy enforcement. Rumination remains Void's slower person-shaped participation/announcement turn. Mood updates typed sleep/speaking pressure and invokes memory maintenance during sleep. - The active self-state service no longer registers, reads, or writes the legacy moderation document during routine operations. - The old memory-organ cleanup/vector-incubation/agency stack, legacy state template, legacy moderation context exporter, legacy moderation-state store, and JSON projection paths are deleted or offline; replacements must be typed-operation native. - Memory maintenance now has a typed runner boundary: `scripts/run-void-memory-maintenance.ps1` builds prompt-facing typed context, loads `prompts/void-memory-maintenance.md`, rejects non-maintenance operations, and applies allowed memory/incubation/candidate proposals through `scripts/void-self-state.mjs`. @@ -226,7 +226,7 @@ landed_baseline: - Long-term memory is durable but plastic. `revise_durable_memory`, `retire_durable_memory`, and `crystallize_memory_into_identity` make revision, contradiction/obsolescence, and self-doctrine crystallization explicit instead of silently overwriting older thoughts. - `npm run moderation:memory-lifecycle:fixture` verifies memory lifecycle flow: short-term promotion, durable revision, superseded-memory retirement, and crystallization into one active identity memory plus self-profile value. - The sleep-maintenance path has a model-branch fixture: `npm run moderation:memory-maintenance:sleep-fixture` seeds a temporary `.cc`, forces mood drift into sleep maintenance, routes the non-skip Codex branch through a fake child, and verifies short-term memory is consumed into one durable meaning-preserving record. - - The local `GameCult Local Orchestrator` scheduled task is installed as the hidden one-minute pulse for Bifrost dispatch, repo Face CTB heartbeats, Void mood drift, Void moderation rumination, and the operations watchdog. It writes per-organ logs under `.voidbot/logs/orchestrator`, summary/status under `.voidbot/status/gamecult-orchestrator*.json`, freezes overlap with one orchestrator lock, and enforces per-organ timeouts so a hung child cannot occupy the whole clock forever. + - The local `GameCult Local Orchestrator` scheduled task is installed as the hidden one-minute pulse for Bifrost dispatch, repo Face CTB heartbeats, Void moderation heartbeat, Void mood drift, Void moderation rumination, and the operations watchdog. It writes per-organ logs under `.voidbot/logs/orchestrator`, summary/status under `.voidbot/status/gamecult-orchestrator*.json`, freezes overlap with one orchestrator lock, and enforces per-organ timeouts so a hung child cannot occupy the whole clock forever. - VoidBot stack startup now treats Bifrost's local Docker Compose stack as a dependency. `scripts/start-voidbot-stack.ps1` checks `BIFROST_HEALTH_URL`, runs `BIFROST_ROOT/compose.local.yaml` when needed, and records `runtime-stack.json.bifrost` before starting the bot and worker. - The typed rumination path now has a model-branch fixture: `npm run moderation:rumination:fixture` seeds a temporary `.cc`, routes the non-skip Codex branch through a fake child, and verifies short-term memory, incubation, agency-pressure, and candidate-intervention proposals cross only through parent-applied typed operations without leaking exact timestamps into the prompt-facing context. - The typed rumination speech path is parent-owned now. Candidate interventions can carry an optional delivery target; the child may queue candidates but must not call Discord send scripts. The parent runner delivers at most one newly queued deliverable candidate per pass, records a delivery receipt, and marks the candidate spoken through `mark_candidate_intervention_spoken`. `npm run moderation:rumination:speech-fixture` verifies this loop with fake Codex and fake Discord send helpers. @@ -258,4 +258,4 @@ rejected_paths: current_status: summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live, and the repo Face swarm has been resumed after the silence/code-block public-post fault. Public speech is now owned by parent Interpreter SAY blocks: the obsolete raw child draft-speech fallback is cut, silence markers and full-message markdown fences are rejected at the transport boundary, and inline Markdown code spans are normalized to quoted human text before Bifrost posting. Live inspection after the follow-up operator report showed the visible code-block examples were pre-repair Discord messages, not fresh post-boundary output. GameCult Local Orchestrator remains the single pulse owner, and the repo Face heartbeat path still obeys `state/agent-swarm-paused.json`. Bifrost work routing remains paused by default. - next_action: Watch the next Discord-visible repo Face heartbeat turns for actual public behavior: no raw silence phrases, no full-message code fences, no child draft speech bypassing Interpreter SAY, and no revival of Bifrost/governance routing while that lane is disabled. + next_action: Watch the next live moderation heartbeat under GameCult Local Orchestrator: it should run every pulse, classify only against rules plus infringement history, and leave any public announcement to Void's normal rumination/CTB turn.