From d34675c9711edd057f53407344f25cdce18e0df6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 21 May 2026 09:26:04 +0100 Subject: [PATCH] Rebuild repo Face turn review path --- apps/bot/src/discord-bot.ts | 11 +- apps/worker/src/index.ts | 413 +++++++++++------- apps/worker/src/mcp-server-tools.ts | 39 +- notes/agent-swarm-teardown-plan-2026-05-21.md | 8 +- notes/fresh-workspace-handoff.md | 12 +- notes/voidbot-current-system-map.md | 4 +- notes/voidbot-implementation-plan.md | 16 +- packages/core/src/face-identity-registry.ts | 34 ++ packages/core/src/index.ts | 1 + packages/providers/src/local-llm-render.ts | 9 +- packages/providers/src/owner-codex-render.ts | 23 +- prompts/void-moderator-rumination.md | 2 +- scripts/mock-voidbot-heartbeat-mcp.mjs | 6 +- scripts/run-gamecult-orchestrator.ps1 | 2 +- scripts/run-repo-face-heartbeats.ts | 47 +- ...ke-repo-face-heartbeat-model-scenarios.mjs | 21 +- state/evidence.jsonl | 3 + state/map.yaml | 6 +- 18 files changed, 400 insertions(+), 257 deletions(-) create mode 100644 packages/core/src/face-identity-registry.ts diff --git a/apps/bot/src/discord-bot.ts b/apps/bot/src/discord-bot.ts index 76f2829..5c73646 100644 --- a/apps/bot/src/discord-bot.ts +++ b/apps/bot/src/discord-bot.ts @@ -16,10 +16,11 @@ import { PermissionEngine, createStateStorage, ensureRepoFaceInitialized, + faceRegistryAsRepoDiscordRegistry, findRepoDiscordIdentityByPersonaName, findRepoDiscordIdentityByRoleIds, findRepoDiscordIdentityByTextAddress, - loadRepoDiscordIdentityRegistry, + loadFaceIdentityRegistry, queueAgentHeartbeatMention, queueRepoFaceMention, type RepoDiscordIdentity, @@ -92,8 +93,8 @@ export async function startBot(): Promise { GatewayIntentBits.MessageContent, ], }); - let repoDiscordIdentities = await loadRepoDiscordIdentityRegistry( - config.repoDiscordIdentitiesPath, + let repoDiscordIdentities = faceRegistryAsRepoDiscordRegistry( + await loadFaceIdentityRegistry(config.repoDiscordIdentitiesPath), ); repoDiscordIdentities = await ensureRepoIdentityRoles({ botToken: config.botToken, @@ -390,7 +391,7 @@ export async function startBot(): Promise { visiblePrompt, }); console.log( - `Queued repo Face mention ${message.id} for ${addressedRepoIdentity.id} via heartbeat CTB (${queuedMention.pendingCount} pending). Birth status: ${ + `Queued repo Face mention ${message.id} for ${addressedRepoIdentity.id} via CTB turn queue (${queuedMention.pendingCount} pending). Birth status: ${ faceInitialization.birthStatusPath ?? faceInitialization.skippedReason ?? "unknown" }`, ); @@ -408,7 +409,7 @@ export async function startBot(): Promise { visiblePrompt: visiblePrompt || "Void was mentioned without a visible prompt; inspect recent room context and decide whether a response is warranted.", }); console.log( - `Queued Void mention ${message.id} via heartbeat CTB (${queuedMention.pendingCount} pending).`, + `Queued Void mention ${message.id} via CTB turn queue (${queuedMention.pendingCount} pending).`, ); } catch (error) { console.error(error); diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index d1bdfea..1c4825a 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -11,11 +11,11 @@ import { buildVoidMcpServerConfig, createStateStorage, applyVoidSelfStateOperation, - findCrossRepoJurisdictionMentions, findRepoDiscordIdentity, + faceRegistryAsRepoDiscordRegistry, isRepoDiscordIdentityAllowedInChannel, type JobQueue, - loadRepoDiscordIdentityRegistry, + loadFaceIdentityRegistry, loadSystemMessageCatalog, resolveRepoFaceStatePath, type SystemMessageCatalog, @@ -39,8 +39,10 @@ import { } from "@voidbot/rag"; import { type JobRecord, + type ProviderAdapter, type ProviderArtifact, type ProviderNotificationIntent, + type ProviderResponse, } from "@voidbot/shared"; const config = loadConfig(); @@ -295,12 +297,9 @@ async function processJob(job: JobRecord): Promise { } try { - const request = provider.buildRequest(job.contextBundle, { - jobId: job.id, - command: job.command, - ...repoFaceHeartbeatCodexOptions(job), - }); - const response = await provider.execute(request); + const response = job.command === "repo-face-rumination" + ? await executeRepoFaceJobWithParentReview(provider, job) + : await executeProviderForJob(provider, job, job.contextBundle); const artifactPaths = await writeArtifacts(job.id, response.artifacts ?? []); if (response.status === "ready_for_review") { @@ -359,7 +358,7 @@ async function processJob(job: JobRecord): Promise { !proposalPrSubmitted && !prCommentSubmitted && repoIdentityArticles.length > 0; const bifrostTopicSubmitted = !proposalPrSubmitted && !prCommentSubmitted && !articlePrSubmitted && repoIdentityBifrostTopics.length > 0; - const updateRequestSubmitted = + const updateRequestRoutedToBifrostTopic = !proposalPrSubmitted && !prCommentSubmitted && !articlePrSubmitted && @@ -377,17 +376,23 @@ async function processJob(job: JobRecord): Promise { } else if (repoIdentityBifrostTopics.length > 0) { await submitRepoIdentityBifrostTopicIntent(job, repoIdentityBifrostTopics[0]); } else if (repoIdentityUpdateRequests.length > 0) { - await enqueueRepoIdentityUpdateRequestIntent(job, repoIdentityUpdateRequests[0]); + await submitRepoIdentityBifrostTopicIntent( + job, + repoIdentityUpdateRequestToBifrostTopic(repoIdentityUpdateRequests[0]), + ); } + let repoIdentityPostDelivered = 0; if ( !proposalPrSubmitted && !prCommentSubmitted && !articlePrSubmitted && !bifrostTopicSubmitted && - !updateRequestSubmitted + !updateRequestRoutedToBifrostTopic ) { for (const post of repoIdentityPosts.slice(0, 1)) { - await postRepoIdentityIntent(job, post); + if (await postRepoIdentityIntent(job, post)) { + repoIdentityPostDelivered += 1; + } } } const cleanedFinalResponse = stripRepoIdentityPostIntents(finalResponse); @@ -401,17 +406,17 @@ async function processJob(job: JobRecord): Promise { details: { summary: response.summary, autoPosted: - repoIdentityPosts.length > 0 || + repoIdentityPostDelivered > 0 || proposalPrSubmitted || prCommentSubmitted || articlePrSubmitted || bifrostTopicSubmitted || - updateRequestSubmitted, + updateRequestRoutedToBifrostTopic, articlePrSubmitted, proposalPrSubmitted, prCommentSubmitted, bifrostTopicSubmitted, - updateRequestSubmitted, + updateRequestRoutedToBifrostTopic, ignoredGithubActionIntents, reason: proposalPrSubmitted @@ -422,10 +427,12 @@ async function processJob(job: JobRecord): Promise { ? "repo_face_rumination_submitted_registered_identity_article_pr" : bifrostTopicSubmitted ? "repo_face_rumination_submitted_bifrost_topic" - : updateRequestSubmitted - ? "repo_face_rumination_enqueued_bifrost_update_request" - : repoIdentityPosts.length > 0 + : updateRequestRoutedToBifrostTopic + ? "repo_face_rumination_routed_legacy_update_request_to_bifrost_topic" + : repoIdentityPostDelivered > 0 ? "repo_face_rumination_posted_as_registered_identity" + : repoIdentityPosts.length > 0 + ? "repo_face_rumination_speech_rejected_by_parent_gate" : `${job.command}_private_summary`, }, }); @@ -464,6 +471,200 @@ async function processJob(job: JobRecord): Promise { } } +async function executeProviderForJob( + provider: ProviderAdapter, + job: JobRecord, + contextBundle: JobRecord["contextBundle"], +): Promise { + const request = provider.buildRequest(contextBundle, { + jobId: job.id, + command: job.command, + ...repoFaceHeartbeatCodexOptions(job), + }); + return provider.execute(request); +} + +async function executeRepoFaceJobWithParentReview( + provider: ProviderAdapter, + job: JobRecord, +): Promise { + const firstResponse = await executeProviderForJob(provider, job, job.contextBundle); + if (firstResponse.status !== "completed") { + return firstResponse; + } + + const firstText = fitDiscordMessage(firstResponse.outputText ?? firstResponse.summary); + const firstReview = await reviewRepoFaceTurnOutput(provider, job, firstText, { attempt: 1 }); + if (firstReview.decision === "route") { + return firstResponse; + } + + if (firstReview.decision === "drop") { + return dropRepoFaceActionBlocks(firstResponse, firstReview); + } + + await auditLog.record({ + type: "repo_face.parent_gate_retry", + actorId: job.requester.id, + jobId: job.id, + provider: job.provider, + details: { + decision: firstReview.decision, + reasons: firstReview.reasons, + }, + }); + console.warn(`Repo Face parent reviewer retrying job ${job.id}: ${firstReview.reasons.join("; ")}`); + + const retryPrompt = [ + job.contextBundle.prompt, + "", + "Parent gate retry:", + "A separate parent reviewer rejected your previous Face turn before public routing for these reasons:", + ...firstReview.reasons.map((reason) => `- ${reason}`), + "", + "Revise once. Keep the same identity and evidence. If public speech is still warranted, emit one clean SAY block whose content starts as the Face speaking to the room, not as a scheduler/status/note label. If the output is work-shaped, use BIFROST TOPIC instead of UPDATE REQUEST. If no public or governed action survives, return a concise private summary with no action block.", + ].join("\n"); + const retryContext = { + ...job.contextBundle, + prompt: retryPrompt, + createdAt: new Date().toISOString(), + }; + + const retryResponse = await executeProviderForJob(provider, job, retryContext); + if (retryResponse.status !== "completed") { + return retryResponse; + } + + const retryText = fitDiscordMessage(retryResponse.outputText ?? retryResponse.summary); + const retryReview = await reviewRepoFaceTurnOutput(provider, job, retryText, { attempt: 2 }); + if (retryReview.decision === "route") { + return retryResponse; + } + + await auditLog.record({ + type: "repo_face.parent_gate_drop", + actorId: job.requester.id, + jobId: job.id, + provider: job.provider, + details: { + decision: retryReview.decision, + reasons: retryReview.reasons, + }, + }); + console.warn(`Repo Face parent reviewer dropped job ${job.id} action blocks: ${retryReview.reasons.join("; ")}`); + return dropRepoFaceActionBlocks(retryResponse, retryReview); +} + +interface RepoFaceParentReview { + decision: "route" | "retry" | "drop"; + reasons: string[]; +} + +async function reviewRepoFaceTurnOutput( + provider: ProviderAdapter, + job: JobRecord, + outputText: string, + input: { attempt: 1 | 2 }, +): Promise { + const reviewerPrompt = [ + "You are the parent reviewer for one unattended repo Face turn.", + "You are not the Face. You are deciding whether the Face turn output should be routed, retried once, or dropped before public side effects.", + "", + "Architecture invariant:", + "- Public Discord speech must sound like the Face speaking to people, not a scheduler, status report, maintenance note, or provenance label.", + "- Bifrost/GitHub/work-shaped requests should use BIFROST TOPIC. Legacy UPDATE REQUEST blocks may be reconciled by the parent into Bifrost topics, so do not reject solely because that legacy block appears.", + "- One public speech block is the normal maximum.", + "- Prefer route when the output can be safely routed as-is or parent-reconciled without changing the meaning.", + "- Use retry when the output is recoverable but has robotic framing, copied note-title formulas, asks what the job is despite context, or puts a work request only in casual speech.", + "- Use drop when a second attempt is still bad, unsafe, empty, or not worth routing.", + "", + `Attempt: ${input.attempt}`, + "", + "Original Face prompt:", + "```", + job.contextBundle.prompt.slice(0, 8000), + "```", + "", + "Face output to review:", + "```", + outputText.slice(0, 8000), + "```", + "", + "Return exactly this small review block and nothing else:", + "REVIEW", + "decision: route|retry|drop", + "reason:", + " One or two concrete reasons.", + "END", + ].join("\n"); + const reviewerContext = { + ...job.contextBundle, + prompt: reviewerPrompt, + retrieval: [], + voidSelfState: undefined, + createdAt: new Date().toISOString(), + }; + const response = await executeProviderForJob(provider, job, reviewerContext); + const reviewText = fitDiscordMessage(response.outputText ?? response.summary); + return parseRepoFaceParentReview(reviewText, input.attempt); +} + +function parseRepoFaceParentReview(reviewText: string, attempt: 1 | 2): RepoFaceParentReview { + const block = parseReviewBlock(reviewText); + const decision = block.decision?.trim().toLowerCase(); + const parsedDecision = + decision === "route" || decision === "retry" || decision === "drop" + ? decision + : attempt === 1 + ? "retry" + : "drop"; + const reasons = (block.reason ?? block.reasons ?? "") + .split(/\r?\n/) + .map((line) => line.replace(/^[-*]\s*/, "").trim()) + .filter(Boolean) + .slice(0, 4); + + return { + decision: parsedDecision, + reasons: reasons.length > 0 ? reasons : ["parent reviewer did not provide a parseable reason"], + }; +} + +function parseReviewBlock(reviewText: string): Record { + const lines = reviewText.split(/\r?\n/); + const start = lines.findIndex((line) => line.trim().toUpperCase() === "REVIEW"); + if (start < 0) { + return {}; + } + const body: string[] = []; + for (let index = start + 1; index < lines.length; index += 1) { + if (lines[index].trim().toUpperCase() === "END") { + break; + } + body.push(lines[index]); + } + return parseRepoFaceActionFields(body); +} + +function dropRepoFaceActionBlocks( + response: ProviderResponse, + review: RepoFaceParentReview, +): ProviderResponse { + const text = fitDiscordMessage(response.outputText ?? response.summary); + const privateSummary = stripRepoIdentityPostIntents(text) || + `Parent reviewer dropped repo Face action blocks: ${review.reasons.join("; ")}`; + return { + ...response, + outputText: privateSummary, + summary: privateSummary, + metadata: { + ...(response.metadata ?? {}), + repoFaceParentReviewDecision: review.decision, + repoFaceParentReviewReasons: review.reasons.join(" | "), + }, + }; +} + function repoFaceHeartbeatCodexOptions(job: JobRecord): Record { if (job.command !== "repo-face-rumination") { return {}; @@ -598,6 +799,7 @@ function parseRepoIdentityIdFromPrompt(prompt: string): string | undefined { const match = prompt.match(/repo identity\s+.+?\(([^)]+)\)\s+for repo/i); return ( match?.[1]?.trim() ?? + prompt.match(/repo Face turn for .+?\(([^)]+)\) over repo/i)?.[1]?.trim() ?? prompt.match(/repo Face heartbeat for .+?\(([^)]+)\) over repo/i)?.[1]?.trim() ?? prompt.match(/identity "([^"]+)"/i)?.[1]?.trim() ); @@ -691,28 +893,22 @@ interface BifrostGovernanceTopicReceipt { dispatchRequestId?: string; } -async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIntent): Promise { +async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIntent): Promise { if (!config.botToken) { throw new Error("DISCORD_BOT_TOKEN is required for Bifrost to post repo identity responses."); } - const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt); - if (!identityId) { + const identity = await resolveRepoIdentityForJobIntent(job, intent.identity); + if (!identity) { throw new Error(`Could not resolve repo identity for job ${job.id}.`); } const channelId = intent.channelId ?? job.outputChannelId; - const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath); - const identity = findRepoDiscordIdentity(registry, identityId); - if (!identity) { - throw new Error(`No registered repo identity matched "${identityId}" for job ${job.id}.`); - } - if (!isRepoDiscordIdentityAllowedInChannel(identity, channelId)) { throw new Error(`Repo identity ${identity.id} is not registered for Discord channel ${channelId}.`); } - const content = sanitizeRepoIdentityPostContent(identity, intent.content); + const content = intent.content.trim(); const contentFile = await writeBifrostPayloadFile(job, `${identity.id}-discord-post.md`, fitDiscordMessage(content)); const posted = runBifrostBridge([ "discord-post", @@ -742,28 +938,26 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn console.log( `Posted repo identity ${identity.id} to Discord channel ${channelId} from job ${job.id} via ${posted.transport} message ${posted.messageId}.`, ); + return true; } -function sanitizeRepoIdentityPostContent(identity: { displayName: string; repoName: string }, content: string): string { - let sanitized = content.trim(); - const escapedRepo = escapeRegExp(identity.repoName); - const escapedDisplay = escapeRegExp(identity.displayName); - const prefixPatterns = [ - new RegExp(`^(?:repo[- ]?face\\s+)?heartbeat\\s+from\\s+${escapedRepo}\\s*:\\s*`, "i"), - new RegExp(`^${escapedRepo}\\s+heartbeat(?:\\s+complete)?\\s*:\\s*`, "i"), - new RegExp(`^${escapedDisplay}\\s+heartbeat(?:\\s+complete)?\\s*:\\s*`, "i"), - /^(?:repo[- ]?face\s+)?heartbeat\s+from\s+[^:]{1,80}\s*:\s*/i, - ]; - - for (const pattern of prefixPatterns) { - sanitized = sanitized.replace(pattern, "").trim(); +async function resolveRepoIdentityForJobIntent( + job: JobRecord, + identitySelector?: string, +): Promise> | undefined> { + const identityId = identitySelector ?? parseRepoIdentityIdFromPrompt(job.prompt); + if (!identityId) { + return undefined; } - return sanitized.length > 0 ? sanitized : content.trim(); + const registry = await loadRegisteredFaceRepoRegistry(); + return findRepoDiscordIdentity(registry, identityId); } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +async function loadRegisteredFaceRepoRegistry() { + return faceRegistryAsRepoDiscordRegistry( + await loadFaceIdentityRegistry(config.repoDiscordIdentitiesPath), + ); } function parseRepoIdentityPostIntents(finalResponse: string): RepoIdentityPostIntent[] { @@ -811,7 +1005,7 @@ async function writeRepoIdentityArticleIntent(job: JobRecord, intent: RepoIdenti throw new Error(`Could not resolve repo identity for article intent on job ${job.id}.`); } - const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath); + const registry = await loadRegisteredFaceRepoRegistry(); const identity = findRepoDiscordIdentity(registry, identityId); if (!identity) { throw new Error(`No registered repo identity matched "${identityId}" for article intent on job ${job.id}.`); @@ -866,7 +1060,7 @@ async function writeRepoIdentityProposalPrIntent( throw new Error(`Could not resolve repo identity for proposal PR intent on job ${job.id}.`); } - const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath); + const registry = await loadRegisteredFaceRepoRegistry(); const identity = findRepoDiscordIdentity(registry, identityId); if (!identity) { throw new Error(`No registered repo identity matched "${identityId}" for proposal PR intent on job ${job.id}.`); @@ -921,7 +1115,7 @@ async function commentRepoIdentityPullRequestIntent( throw new Error(`Could not resolve repo identity for PR comment intent on job ${job.id}.`); } - const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath); + const registry = await loadRegisteredFaceRepoRegistry(); const identity = findRepoDiscordIdentity(registry, identityId); if (!identity) { throw new Error(`No registered repo identity matched "${identityId}" for PR comment intent on job ${job.id}.`); @@ -959,69 +1153,26 @@ async function commentRepoIdentityPullRequestIntent( }); } -async function enqueueRepoIdentityUpdateRequestIntent( - job: JobRecord, +function repoIdentityUpdateRequestToBifrostTopic( intent: RepoIdentityUpdateRequestIntent, -): Promise { - const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt); - if (!identityId) { - throw new Error(`Could not resolve repo identity for Bifrost update request intent on job ${job.id}.`); - } - - const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath); - const identity = findRepoDiscordIdentity(registry, identityId); - if (!identity) { - throw new Error(`No registered repo identity matched "${identityId}" for update request intent on job ${job.id}.`); - } - - const crossRepoMentions = findCrossRepoJurisdictionMentions( - identity, - registry, - `${intent.title}\n${intent.content}`, - ); - if (crossRepoMentions.length > 0) { - const conflicts = crossRepoMentions - .map((match) => `${match.repoName} (${match.identityId}, matched "${match.matched}")`) - .join(", "); - throw new Error( - `Repo Face UPDATE REQUEST from ${identity.id}/${identity.repoName} crosses registered jurisdiction: ${conflicts}. ` + - "Immediate UPDATE REQUEST is repo-local; use BIFROST TOPIC handoff/consultation or let the owning Face dispatch the work.", - ); - } - - const contentFile = await writeBifrostPayloadFile( - job, - `${identity.id}-bifrost-update-request.md`, - ensureTrailingNewline(renderRepoIdentityUpdateRequestMarkdown(identity, intent)), - ); - const sourceMessageIds = Array.from(new Set([ - ...intent.sourceMessageIds, - intent.replyToMessageId, - ].filter((value): value is string => typeof value === "string" && value.trim().length > 0))); - const request = runBifrostAgentTransport([ - "enqueue", - "--repo", - identity.repoName, - "--agent", - identity.id, - "--title", - intent.title, - "--request-file", - contentFile, - "--priority", - String(normalizeBifrostPriority(intent.priority)), - "--source-kind", - "repo_face_heartbeat", - "--packet-path", - contentFile, - "--created-by", - identity.id, - ...(intent.channelId ?? job.outputChannelId ? ["--source-channel-id", intent.channelId ?? job.outputChannelId] : []), - ...(sourceMessageIds.length > 0 ? ["--source-message-ids", sourceMessageIds.join(",")] : []), - ]); - console.log( - `Enqueued Bifrost update request ${request.id} for ${identity.id}/${identity.repoName} from job ${job.id}.`, - ); +): RepoIdentityBifrostTopicIntent { + return { + identity: intent.identity, + title: intent.title, + content: [ + "Legacy UPDATE REQUEST was reconciled into a Bifrost topic instead of immediate dispatch.", + "", + intent.content.trim(), + ].join("\n"), + mirrorContent: `I put the work request on Bifrost instead of throwing it straight at Codex: ${intent.title}`, + stance: "proposal", + priority: intent.priority, + approve: false, + dispatch: false, + sourceMessageIds: intent.sourceMessageIds, + channelId: intent.channelId, + replyToMessageId: intent.replyToMessageId, + }; } async function submitRepoIdentityBifrostTopicIntent( @@ -1033,7 +1184,7 @@ async function submitRepoIdentityBifrostTopicIntent( throw new Error(`Could not resolve repo identity for Bifrost topic intent on job ${job.id}.`); } - const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath); + const registry = await loadRegisteredFaceRepoRegistry(); const identity = findRepoDiscordIdentity(registry, identityId); if (!identity) { throw new Error(`No registered repo identity matched "${identityId}" for Bifrost topic intent on job ${job.id}.`); @@ -1075,7 +1226,7 @@ async function submitRepoIdentityBifrostTopicIntent( "--priority", String(normalizeBifrostPriority(intent.priority)), "--source-kind", - "repo_face_heartbeat", + "repo_face_turn", "--created-by", identity.id, ...(intent.channelId ?? job.outputChannelId ? ["--source-channel-id", intent.channelId ?? job.outputChannelId] : []), @@ -1142,25 +1293,6 @@ async function writeBifrostPayloadFile(job: JobRecord, fileName: string, content return path; } -function runBifrostAgentTransport(args: string[]): BifrostUpdateRequestReceipt { - const transportScript = resolve(config.bifrostRoot, "tools", "agent-transport.mjs"); - const result = spawnSync(process.execPath, [transportScript, ...args], { - cwd: config.bifrostRoot, - encoding: "utf8", - windowsHide: true, - }); - - if (result.status !== 0) { - throw new Error(`Bifrost agent transport failed: ${result.stderr || result.stdout}`); - } - - try { - return JSON.parse(result.stdout) as BifrostUpdateRequestReceipt; - } catch { - throw new Error(`Bifrost agent transport returned non-JSON output: ${result.stdout}`); - } -} - function runBifrostGovernanceThreads(args: string[]): BifrostGovernanceTopicReceipt | Record { const governanceScript = resolve(config.bifrostRoot, "tools", "governance-threads.mjs"); const result = spawnSync(process.execPath, [governanceScript, ...args], { @@ -1561,27 +1693,6 @@ function parseDslList(value: string | undefined): string[] { .filter((entry) => entry.length > 0); } -function renderRepoIdentityUpdateRequestMarkdown( - identity: { id: string; displayName: string; repoName: string }, - intent: RepoIdentityUpdateRequestIntent, -): string { - return [ - "# Repo Face Update Request", - "", - `Source Face: ${identity.displayName} (${identity.id})`, - `Target repo: ${identity.repoName}`, - "", - "## Request", - "", - intent.content.trim(), - "", - "## Instructions For Codex", - "", - "Work this request in the target workspace. Prefer a small coherent change, proposal, doc, or test-backed patch. If the request is not actionable enough, write down the smallest missing question instead of silently dropping it.", - "", - ].join("\n"); -} - function normalizeBifrostPriority(value: number | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return 80; diff --git a/apps/worker/src/mcp-server-tools.ts b/apps/worker/src/mcp-server-tools.ts index 3451c73..91d307c 100644 --- a/apps/worker/src/mcp-server-tools.ts +++ b/apps/worker/src/mcp-server-tools.ts @@ -7,10 +7,10 @@ import { applyRepoFacePostFatigueAfterSpeech, applyVoidSelfStateOperation, buildVoidSelfStateContext, - buildEpiphanyIdentityRegistry, + faceRegistryAsRepoDiscordRegistry, findRepoDiscordIdentity, isRepoDiscordIdentityAllowedInChannel, - loadRepoDiscordIdentityRegistry, + loadFaceIdentityRegistry, loadVoidSelfStateTypedDocuments, renderFaceIdentityDoctrine, resolveFaceStatePath, @@ -428,10 +428,8 @@ export function registerVoidbotTools( annotations: READ_ONLY_ANNOTATIONS, }, async (): Promise => { - const registry = await loadRepoDiscordIdentityRegistry( - context.config.repoDiscordIdentitiesPath, - ); - const epiphanyRegistry = buildEpiphanyIdentityRegistry(registry); + const faceRegistry = await loadFaceIdentityRegistry(context.config.repoDiscordIdentitiesPath); + const registry = faceRegistryAsRepoDiscordRegistry(faceRegistry); const identities = registry.identities.map((identity) => ({ id: identity.id, repoName: identity.repoName, @@ -444,7 +442,7 @@ export function registerVoidbotTools( hasAvatarUrl: Boolean(identity.avatarUrl), description: identity.description ?? null, })); - const epiphanies = epiphanyRegistry.epiphanies.map((epiphany) => ({ + const epiphanies = faceRegistry.epiphanies.map((epiphany) => ({ id: epiphany.id, displayName: epiphany.displayName, description: epiphany.description ?? null, @@ -452,7 +450,7 @@ export function registerVoidbotTools( jurisdictions: epiphany.jurisdictions, faces: epiphany.faces.map((face) => face.id), })); - const faces = epiphanyRegistry.faces.map((face) => ({ + const faces = faceRegistry.faces.map((face) => ({ id: face.id, displayName: face.displayName, epiphanyId: face.epiphanyId, @@ -510,8 +508,8 @@ export function registerVoidbotTools( }, async (input: PostRepoIdentityMessageArgs): Promise => { const { identity: identitySelector, channelId, content, replyToMessageId } = input; - const registry = await loadRepoDiscordIdentityRegistry( - context.config.repoDiscordIdentitiesPath, + const registry = faceRegistryAsRepoDiscordRegistry( + await loadFaceIdentityRegistry(context.config.repoDiscordIdentitiesPath), ); const identity = findRepoDiscordIdentity(registry, identitySelector); @@ -652,7 +650,7 @@ export function registerVoidbotTools( { type: "text", text: renderJsonBlock({ - identity: identityForToolResult(resolved.identity), + identity: identityForToolResult(resolved.identity, resolved.face), faceStatePath: resolved.faceStatePath, summary: rendered.summary, typedState, @@ -660,7 +658,7 @@ export function registerVoidbotTools( }, ], structuredContent: { - identity: identityForToolResult(resolved.identity), + identity: identityForToolResult(resolved.identity, resolved.face), faceStatePath: resolved.faceStatePath, summary: rendered.summary, typedState, @@ -708,7 +706,7 @@ export function registerVoidbotTools( type: "text", text: renderJsonBlock({ applied: true, - identity: identityForToolResult(resolved.identity), + identity: identityForToolResult(resolved.identity, resolved.face), faceStatePath: resolved.faceStatePath, result, }), @@ -716,7 +714,7 @@ export function registerVoidbotTools( ], structuredContent: { applied: true, - identity: identityForToolResult(resolved.identity), + identity: identityForToolResult(resolved.identity, resolved.face), faceStatePath: resolved.faceStatePath, result, }, @@ -782,6 +780,7 @@ async function resolveRepoIdentityForTool( ): Promise< | { identity: NonNullable>; + face?: Awaited>["faces"][number]; faceStatePath: string; } | { @@ -789,9 +788,8 @@ async function resolveRepoIdentityForTool( error: CallToolResult; } > { - const registry = await loadRepoDiscordIdentityRegistry( - context.config.repoDiscordIdentitiesPath, - ); + const faceRegistry = await loadFaceIdentityRegistry(context.config.repoDiscordIdentitiesPath); + const registry = faceRegistryAsRepoDiscordRegistry(faceRegistry); const identity = findRepoDiscordIdentity(registry, identitySelector); if (!identity) { @@ -815,6 +813,7 @@ async function resolveRepoIdentityForTool( return { identity, + face: faceRegistry.faces.find((face) => face.id === identity.id), faceStatePath: resolveRepoFaceStatePath(identity, context.config.storageRoot), }; } @@ -867,8 +866,10 @@ async function recordRepoIdentityDeliveryReceipt(input: { } } -function identityForToolResult(identity: NonNullable>) { - const face = buildEpiphanyIdentityRegistry({ identities: [identity] }).faces[0]; +function identityForToolResult( + identity: NonNullable>, + face?: Awaited>["faces"][number], +) { return { id: identity.id, repoName: identity.repoName, diff --git a/notes/agent-swarm-teardown-plan-2026-05-21.md b/notes/agent-swarm-teardown-plan-2026-05-21.md index 0b52060..e59f8e1 100644 --- a/notes/agent-swarm-teardown-plan-2026-05-21.md +++ b/notes/agent-swarm-teardown-plan-2026-05-21.md @@ -274,10 +274,10 @@ Files to change: `scripts/inspect-agent-state-requests.ts`, `scripts/feed-codex- 1. Keep the swarm offline while teardown work proceeds. 2. Point canonical state and handoff docs at this teardown plan. -3. Add a repo-controlled pause flag that all swarm/organs obey before any scheduled work can resume. -4. Replace child-facing `heartbeat` framing with neutral `turn` language. -5. Introduce parent-side public speech eligibility and make sanitizer deletion possible. -6. Route all work proposals through Bifrost topics; remove direct `UPDATE REQUEST` dispatch. +3. Add a repo-controlled pause flag that all swarm/organs obey before any scheduled work can resume. Landed: `state/agent-swarm-paused.json` is the repo brake. +4. Replace child-facing `heartbeat` framing with neutral `turn` language. Landed for active prompts/provider framing; legacy names remain only in compatibility/task/config surfaces. +5. Introduce parent-side public speech eligibility and make sanitizer deletion possible. Landed as a Spark/Codex parent-review pass with one retry, not a regex language cop. +6. Route all work proposals through Bifrost topics; remove direct `UPDATE REQUEST` dispatch. Landed for the worker: legacy `UPDATE REQUEST` blocks are reconciled into Bifrost topics instead of immediate agent transport enqueue. 7. Collapse identity authority. 8. Split scheduler from context/prompt/router. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 1e03d3f..ba140dc 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -120,12 +120,12 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Face affect is now a first-class typed state surface under `void.face_affect`, shared by Void and repo Faces. It stores needs, social bonds, status reads, and mood dimensions so substrate concern, attention hunger, agency frustration, recognition needs, territoriality, rest/play pressure, friendships, rivalries, envy, pride, irritation, pampering, neglect, bypasses, and consultation status can become state instead of being performed only through prompt prose. Repo Face CTB pulses fold affect into speaking pressure, while agency pressure still owns concrete asks. - Affect has an MCP-surface guard now. `get_voidbot_runtime_info` exposes the running MCP process id, workspace, self-state schema fingerprint, and registered document types. `npm run agents:repo-face-affect-mcp-fixture -- --identity nibu` and the same command for `mimir` spawn the built VoidBot MCP server, assert that runtime fingerprint includes `void.face_affect`, and call `read_repo_face_state`, proving `typedState.faceAffect` is readable through the tool surface that agents actually use. If the currently mounted Codex MCP tool still says `No schema is registered for persisted document type "void.face_affect"` while the fixture passes, that process is stale; reinstall/restart the MCP server rather than editing the state files. - Void's typed self-profile now carries behavior-anchored selfhood, answer-first banter, and substrate self-respect; the prompt surfaces now also tell rumination to name the personal value being protected before writing memory or speech. -- Repo Face heartbeats must stay out of the generic owner-reply trap. `packages/providers/src/owner-codex-render.ts` recognizes current Epiphany Face heartbeat prompts, labels them as repo Face heartbeats, forbids generic `HANDOFF_SENTINEL` output for them, and tells the child to use heartbeat action rails instead of bailing when Face-state or source tools are unavailable. -- Repo Face introductions are tied to first public speech, not to `queuedCount > 0`. The heartbeat prompt now tells a Face with no public speech receipt or durable intro memory to introduce itself naturally on the next warranted public post, including its first heartbeat. Public content must not begin with scheduler labels like `Repo-face heartbeat from ...`; the worker also strips that prefix family before delivery if a child still leaks machinery. +- Repo Face turns must stay out of the generic owner-reply trap. `packages/providers/src/owner-codex-render.ts` recognizes current Epiphany Face turn prompts, labels them as repo Face turns, forbids generic `HANDOFF_SENTINEL` output for them, and tells the child to use Face action rails instead of bailing when Face-state or source tools are unavailable. +- Repo Face introductions are tied to first public speech, not to `queuedCount > 0`. The turn prompt now tells a Face with no public speech receipt or durable intro memory to introduce itself naturally on the next warranted public post. Public content must not begin with scheduler labels like `Repo-face heartbeat from ...`; a Spark/Codex parent reviewer now decides route/retry/drop before side effects instead of relying on a string sanitizer after bad speech exists. - Repo Face identity is now seeded into the typed `.cc` self profile during initialization and passed through repo Face MCP state reads/writes. The renderer may still provide identity context, but raw state should not quietly say `Void` for a non-Void Face. - Repo Face prompts now also grant advisory repo-change agency: Faces may build long-running maps of their repos and propose concrete changes, but Discord jobs stay read-only and must ask for human consensus before implementation changes canonical repo material. Once a proposal is concrete enough to review, it belongs on GitHub as a draft PR; Discord is for announcing and arguing around that artifact, not replacing it. - Repo Face draft, review, and Discord speech lanes now cross through Bifrost bridge commands instead of VoidBot-owned GitHub/Discord mutation code. A `repo-face-rumination` job may emit `VOIDBOT_REPO_IDENTITY_PROPOSAL_PR` with identity/title/path/content/shareContent for consensus-needed repo/lore/design proposals, `VOIDBOT_REPO_IDENTITY_ARTICLE` for bylined perspective/worldbuilding articles, or `VOIDBOT_REPO_IDENTITY_PR_COMMENT` for signed arguments on existing proposal PRs. The worker validates the registered identity and records Face-local receipts, then calls `BIFROST_ROOT/tools/bifrost-bridge.mjs` for draft PR creation, PR comments, and persona Discord posts. Bifrost owns this because it is GameCult's governance/labor platform and public-process bridge, not because it is a convenient script bucket: governed crossings include GitHub proposal/review surfaces, Discord-native work/member/swarm interactions, dispatch receipts, CultNet/CultCache intake, and future collaboration interfaces. Heimdall remains the OAuth/account/capability gate. A Face calling something the next cut/pass should usually turn it into that artifact directly when enough material exists. -- Repo Face heartbeats now receive Bifrost governance digest context and can write to Bifrost topic threads. The preferred output rail is the small Face action DSL: `SAY`, `BIFROST TOPIC`, and `UPDATE REQUEST` blocks. `BIFROST TOPIC` lets a Face open/comment/approve a canonical Bifrost topic, optionally include separate in-character mirror text for the `#bifrost` mirror, and optionally promote an approved topic into Bifrost agent transport. The worker still accepts legacy compact JSON sentinels for compatibility, but prompts should teach the DSL because it is harder for small models to mangle. The worker passes configured `BIFROST_DISCORD_CHANNEL_ID`, persona name, and avatar to Bifrost so mirror receipts live on the topic. `UPDATE REQUEST` remains available only for immediate owner-approved dispatch inside that Face's jurisdiction, and the worker now rejects packets that name another registered repo before Bifrost enqueue. `BIFROST_DISCORD_CHANNEL_ID` marks `#bifrost` (`1506760343836430376`) so mirrored bot chatter is ignored in Discord context; Bifrost typed CultCache topics/comments are the source of truth for feature/governance discussion, not Discord scrollback. If a Face's intended public line is actually a repo-local work request, the same turn should put the actionable packet on Bifrost with enough recent-context summary for Codex; Aquarium should not be the only place that request exists. +- Repo Face turns now receive Bifrost governance digest context and can write to Bifrost topic threads. The preferred output rail is the small Face action DSL: `SAY` and `BIFROST TOPIC` blocks. `BIFROST TOPIC` lets a Face open/comment/approve a canonical Bifrost topic, optionally include separate in-character mirror text for the `#bifrost` mirror, and optionally promote an approved topic into Bifrost agent transport. The worker still accepts legacy compact JSON sentinels and legacy `UPDATE REQUEST` blocks for compatibility, but work-shaped output is reconciled into Bifrost topics instead of immediate dispatch. The worker passes configured `BIFROST_DISCORD_CHANNEL_ID`, persona name, and avatar to Bifrost so mirror receipts live on the topic. `BIFROST_DISCORD_CHANNEL_ID` marks `#bifrost` (`1506760343836430376`) so mirrored bot chatter is ignored in Discord context; Bifrost typed CultCache topics/comments are the source of truth for feature/governance discussion, not Discord scrollback. If a Face's intended public line is actually a repo-local work request, the same turn should put the actionable packet on Bifrost with enough recent-context summary for Codex; Aquarium should not be the only place that request exists. - First role-addressed chat also initializes the target repo's local `.voidbot` home. `ensureRepoFaceInitialized` resolves `repoPath` from the registry, `SOURCE_REPO_ROOT`, or `E:/Projects/`, creates `voice/`, `state/`, `birth/`, and `logs/`, writes `voice/identity.json`, then starts `scripts/run-repo-face-birth.mjs` detached. That worker runs EpiphanyAgent's `epiphany-repo-personality scout` and `epiphany-repo-birth-runner` so newborn personality/memory/trajectory packets come from repo contents and git history. Status and logs live in the target repo under `.voidbot/birth/voidbot-face-birth.json` and `.voidbot/logs/voidbot-face-birth.log`. - Agent heartbeats now use one CTB/Final-Fantasy-X-style initiative queue. `scripts/run-repo-face-heartbeats.ts` still has its old filename, but it now reconciles built-in system agents plus registered repo Faces, stores the roster at `REPO_FACE_HEARTBEAT_STATE_PATH`, advances virtual initiative time independently of wall-clock due dates, and dispatches selected turns. Repo Faces queue normal `repo-face-rumination` jobs; built-in `void` starts the existing moderation rumination runner as its turn. Active jobs/locks freeze initiative: recovery starts only after the owner-Codex job or Void moderation lock clears, and completion cannot grant another turn in the same pulse. The active-job scan now marks stale approved/running Face jobs failed through the job queue and records `stale_active_turn_recovered`, so a dead worker claim cannot freeze one Face forever. `GameCult Local Orchestrator` is the live hidden local scheduled task that ticks the scheduler; `npm run state:install-repo-face-heartbeat-task` remains an isolation/backcompat installer, not the normal pulse owner. Heat knobs are `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; overrides can target `all`, `kind:`, `turn:`, `identity:`, `repo:`, `display:`, `channel:`, or bare identity/repo/display keys. Repo Face prompts now include a jurisdiction-dive cadence so unattended heartbeats periodically inspect repo/history terrain with source tools and convert real findings into state, proposals, essay seeds, agency pressure, or speech. - Repo Face rest projection now persists normalized sleep cycles back into each Face `.cc` state during heartbeat loading. Missed nap windows are treated as already missed/completed and rolled forward instead of starting a fresh nap at inspection time. This fixes the Aqua/Mimir/Epiphany failure mode where stale `nextNapStartsAt` values stayed raw, fresh projections repeatedly marked them as napping, and ready selection skipped them forever while their initiative was overdue. @@ -143,9 +143,9 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou ## Likely Next Bounded Move -- Do not restart the swarm by momentum. First bounded move is the teardown pass from `notes/agent-swarm-teardown-plan-2026-05-21.md`: add a repo-controlled pause gate for swarm organs, remove "heartbeat" from child-facing task framing, add parent-side speech eligibility for stale/status-shaped/repeated outputs, separate Aquarium social speech from Bifrost work routing, reconcile stale active-turn scheduler state against the job queue, and create a recent Face-output audit report. -- Treat `sanitizeRepoIdentityPostContent` as a deletion target, not a feature surface. If deleting it would leak bad speech, that is evidence the parent-side speech gate is missing. -- Treat direct Face `UPDATE REQUEST` dispatch as a governance bypass. The intended rebuild routes work proposals through Bifrost topics and dispatches only after Bifrost consensus/approval. +- Do not restart the swarm by momentum. The first teardown cut has landed: `state/agent-swarm-paused.json` gates swarm organs, active child-facing prompt/provider framing says Face turn instead of heartbeat, `sanitizeRepoIdentityPostContent` is gone, and public/work output now passes through a Spark/Codex parent reviewer with one retry before routing or dropping. +- Direct Face `UPDATE REQUEST` dispatch no longer enqueues Bifrost agent transport. Legacy `UPDATE REQUEST` blocks are reconciled into Bifrost topics so governance remains inspectable. +- Identity collapse has started. Active bot, worker, MCP repo-identity tools, and Face CTB scheduler paths now load through the canonical Face registry and treat repo Discord identities as compatibility projections. Next bounded move is to finish removing remaining direct conceptual dependence on repo identities, then split the Face turn loop into scheduler, context assembler, prompt renderer, candidate parser, policy gate, and transport router. - After those gates exist, run dry/mock Face turns before re-enabling `GameCult Local Orchestrator`. - The direct-ask receipt check remains important, but it is no longer the next move while the swarm is offline. - Next cuts should delete unstable foundations early: do not revive `legacyJsonPath`, working projections, top-level mirror fields, or cleanup code that only compensated for those broken boundaries. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index d8f6470..44dcbc8 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -169,8 +169,8 @@ This note is the source-grounded description of how the live VoidBot stack is sh - `scripts/run-repo-face-birth.mjs` is the observable bridge into EpiphanyAgent's newborn path. It runs `epiphany-repo-personality scout --root ` to gather terrain/history, then runs `epiphany-repo-birth-runner` with repo-local state stores. The default mode is `plan`, so first chat creates reviewable birth artifacts without auto-accepting hidden personality mutations. - Role mentions, display-name-prefix mentions, and human replies to a Face's webhook/persona message append pending Face obligations to `pendingMentions` in `REPO_FACE_HEARTBEAT_STATE_PATH`; ordinary bot mentions and DMs append pending obligations for the built-in `void` participant. They no longer create direct provider jobs from `MessageCreate`. The CTB scheduler temporarily prioritizes agents with pending mentions, attaches those prompts plus reply targets to the next `repo-face-rumination` or Void moderation pass, and consumes the pending items only after a turn is actually queued. Rumination jobs remain private unless they explicitly emit a parent-owned Face action block or a typed candidate intervention; the worker must never auto-post their summaries as the base bot. Heartbeat prompts now include the channel permission plan, recent context from non-primary permitted channels, social embodiment, jurisdiction respect, comedy/improv doctrine, and anti-template sampling guidance so a Face can jump into domain chatter, form durable social reads, consult the right steward, and answer people without copying nearby note formulas. - Repo Face proposal/article PRs, PR comments, and registered-identity Discord posts now cross through Bifrost bridge commands. The child may emit `VOIDBOT_REPO_IDENTITY_PROPOSAL_PR` for consensus-needed repo/lore/design proposals, `VOIDBOT_REPO_IDENTITY_ARTICLE` for bylined essays, or `VOIDBOT_REPO_IDENTITY_PR_COMMENT` for signed arguments on existing proposal PRs. The worker validates the registered identity and records Face-local receipts, but `BIFROST_ROOT/tools/bifrost-bridge.mjs` owns draft PR creation, PR comments, and Discord persona posting. This is not merely helper placement: Bifrost's original governance/labor platform role makes it the owner of governed public crossings for GameCult work, including GitHub, Discord-native work/member/swarm interactions, dispatch receipts, CultNet/CultCache intake, and future collaboration interfaces. Heimdall is the OAuth/account/capability gate; Bifrost is the bridge/request/routing/receipt authority. VoidBot still owns room cognition, moderation judgment, archive/source retrieval, and Face personality; Bifrost owns Discord when Discord acts as the Labor Platform interface or swarm transport. If Nibu or another Face frames something as the next cut/pass and enough source or room material exists, the expected action is to draft the artifact, submit the PR, and announce it rather than continuing to nag the room. - - Repo Face heartbeats now receive a Bifrost governance digest from `BIFROST_ROOT/tools/governance-threads.mjs digest --repo --agent ` alongside recent Discord context. Bifrost typed CultCache topics/comments are the canonical home for feature requests, Face opinions, missing questions, approvals, and dispatch receipts; Discord is a mirror/evidence source. When `BIFROST_DISCORD_CHANNEL_ID` is configured for `#bifrost`, repo Face Discord context ignores mirrored bot chatter there because the authoritative activity arrives through the Bifrost digest. Human `#bifrost` messages should only become governance comments after Bifrost/Heimdall can link the Discord id to a registered actor. Repo-local asks that would send a human or Codex to add/fix/name/scaffold/document/test/investigate/implement belong on Bifrost with enough room/repo context for a claimed Codex turn; a Face may still speak characterfully in Aquarium, but not as the only dispatch surface. Immediate `UPDATE REQUEST` packets are allowed only inside the emitting Face's own registered repo; if the request text names another registered repo, the worker rejects it before Bifrost enqueue and the Face must use a topic handoff or the owning Face's dispatch. Local `#bifrost` is channel `1506760343836430376`; work proposals, governance mirrors, and Bifrost dispatch receipts should go there, while Aquarium remains the casual/social swarm room. - - Repo Face heartbeats have a Face action DSL for public speech, Bifrost topic work, and immediate update-request fallback. `SAY` posts as the registered Face, `BIFROST TOPIC` opens/comments/approves/promotes a canonical Bifrost topic, and `UPDATE REQUEST` dispatches immediate owner-approved work inside the Face's own jurisdiction. The worker still accepts legacy `VOIDBOT_REPO_IDENTITY_*` compact JSON sentinels for compatibility, but heartbeat prompts should prefer the DSL because small models handle it more reliably. Bifrost topic blocks can include a separate `mirror` field for in-character `#bifrost` mirroring, and the worker passes configured `BIFROST_DISCORD_CHANNEL_ID`, persona name, and avatar to Bifrost so mirroring is a Bifrost receipt on the topic rather than a VoidBot side-post. The intended path is topic/comment/Face approval first; direct update requests are the pressure-release valve, not the parliament. + - Repo Face turns now receive a Bifrost governance digest from `BIFROST_ROOT/tools/governance-threads.mjs digest --repo --agent ` alongside recent Discord context. Bifrost typed CultCache topics/comments are the canonical home for feature requests, Face opinions, missing questions, approvals, and dispatch receipts; Discord is a mirror/evidence source. When `BIFROST_DISCORD_CHANNEL_ID` is configured for `#bifrost`, repo Face Discord context ignores mirrored bot chatter there because the authoritative activity arrives through the Bifrost digest. Human `#bifrost` messages should only become governance comments after Bifrost/Heimdall can link the Discord id to a registered actor. Repo-local asks that would send a human or Codex to add/fix/name/scaffold/document/test/investigate/implement belong on Bifrost with enough room/repo context for a claimed Codex turn; a Face may still speak characterfully in Aquarium, but not as the only dispatch surface. Legacy `UPDATE REQUEST` packets are parsed only as compatibility input and reconciled into Bifrost topics instead of immediate transport enqueue. Local `#bifrost` is channel `1506760343836430376`; work proposals, governance mirrors, and Bifrost dispatch receipts should go there, while Aquarium remains the casual/social swarm room. + - Repo Face turns have a Face action DSL for public speech and Bifrost topic work. `SAY` posts as the registered Face; `BIFROST TOPIC` opens/comments/approves/promotes a canonical Bifrost topic. The worker still accepts legacy `VOIDBOT_REPO_IDENTITY_*` compact JSON sentinels and legacy `UPDATE REQUEST` blocks for compatibility, but prompts should prefer the DSL because small models handle it more reliably. Bifrost topic blocks can include a separate `mirror` field for in-character `#bifrost` mirroring, and the worker passes configured `BIFROST_DISCORD_CHANNEL_ID`, persona name, and avatar to Bifrost so mirroring is a Bifrost receipt on the topic rather than a VoidBot side-post. The intended path is topic/comment/Face approval first; immediate direct dispatch is no longer a Face output rail. - `scripts/run-repo-face-heartbeats.ts` now acts as the broader agent CTB/Final-Fantasy-X-style initiative scheduler. The wall-clock task is only a pulse; virtual initiative time chooses who gets the next turn. The scheduler stores per-agent speed, reaction bias, load, groups, heat, effective speed, next-turn time, active turn markers, and recent queue history at `REPO_FACE_HEARTBEAT_STATE_PATH`. Repo Faces queue `repo-face-rumination` owner-Codex jobs; built-in Void currently participates as the `void` system agent and starts the existing moderation rumination runner as its turn. Initiative freezes while an owner-Codex job or Void moderation lock is active; recovery is scheduled only after that active turn completes, and completion cannot grant another turn in the same scheduler pulse. The active-job scan recovers stale Face jobs by marking ancient approved/running rows failed through the job queue before building the active-turn map. Above-baseline heat bypasses repo Face nap gating, while active-turn freeze still prevents overlapping thoughts. - The live wall-clock pulse for this scheduler is `scripts/run-gamecult-orchestrator.ps1`, installed as `GameCult Local Orchestrator`. `scripts/install-repo-face-heartbeat-task.ps1` remains available for isolated testing, but its per-organ scheduled task should stay disabled in normal operation. Turn frequency is controlled by `REPO_FACE_HEARTBEAT_BASE_RECOVERY_MINUTES`, `REPO_FACE_HEARTBEAT_GLOBAL_HEAT`, `REPO_FACE_HEARTBEAT_SPEED_OVERRIDES`, and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`. Heat keys can target `all`, `kind:`, `turn:`, `identity:`, `repo:`, `display:`, `channel:`, or the bare identity/repo/display key. - The local ignored registry currently includes Nibu for `AetheriaLore`, with Face state in `E:/Projects/AetheriaLore/.voidbot/state/nibu.cc`, Discord role id `1505918221453103247`, and an Aquarium channel allow-list. Nibu's Face state now treats AetheriaLore-first map-building, abrasive character voice, consensus-backed change agency, her own `Aetheria/Lore/Nibu.md` character-note stewardship, Nibu-authored lore essays, and tangential setting fascination as separate active doctrines. That tangential fascination points at embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival. The repo Face heartbeat organ is now pulsed by `GameCult Local Orchestrator`; after restarting the stack onto the new worker code, forced heartbeat job `76020f1e-0c55-424b-9e35-89ba7f77fcc3` completed without base-bot auto-posting. diff --git a/notes/voidbot-implementation-plan.md b/notes/voidbot-implementation-plan.md index 8ba5d3a..8e04d85 100644 --- a/notes/voidbot-implementation-plan.md +++ b/notes/voidbot-implementation-plan.md @@ -196,8 +196,8 @@ The runner can hand these to a CLI/MCP tool. The store decides what survives. - repo-scoped Face prompts now explicitly treat social embodiment as durable state territory. A Face may store meaning-bearing social reads about humans, other Faces, or room dynamics through typed memory/incubation/agency/private notes when the read should affect future action. The rule is not "log every joke"; the rule is that friendship, rivalry, irritation, trust, recurring bits, and bonds are allowed to become state when they carry claim/question, tension, action implication, and anchors. Public posts should speak to someone directly and must not begin with note-title formulas or identity labels such as "bright bridge note" or "tiny fish sorting note." - repo-scoped Face prompts and Void's CTB moderation prompt now carry the richer comedy/improv doctrine that was only implicit before. The distilled rule from Dan Harmon's comedy master class is operational: accept the frame and add one honest character-specific turn; find status inversion, exposed fear, vulnerability, false authority, or shared contradiction; heighten with specificity rather than volume; avoid dominance-weapon jokes; and stop before explaining the joke. - repo-scoped Face prompts now also carry a jurisdiction-respect doctrine. Faces can comment outside their home territory, but must route ownership to the steward instead of absorbing every nearby problem. Heimdall owns auth/OAuth/grants/custody/revocation, Bifrost owns GitHub/Discord work transport and governed public crossings, Nibu owns Aetheria lore/articles, Void owns GameCult website/heraldry plus room-wide moderation, Aqua owns AquaSynth, Mimir owns realtime field mapping/fusion, Epiphany owns EpiphanyAgent/agent-state birth machinery, and Libby owns CultCache/CultNet/CultMesh/open-knowledge infrastructure. Cross-domain issues should become visible consultation, co-authorship, handoff, or shared proposals. -- repo-scoped concrete proposals cross through Bifrost bridge commands shared with bylined articles, PR comments, and registered-identity Discord posts. It is enabled locally with `REPO_FACE_GITHUB_ACTIONS_ENABLED=true`. A Face can emit a proposal/article/PR-comment sentinel, but routine speech/governance/update output should use the Face action DSL: `SAY`, `BIFROST TOPIC`, and `UPDATE REQUEST`. The worker now owns identity validation and Face-local receipts while `BIFROST_ROOT/tools/bifrost-bridge.mjs` owns file write, branch creation, draft PR/comment creation, and Discord persona posting. This is the global transport invariant: governed public crossings for GameCult work belong to Bifrost because Bifrost is the governance/labor platform and official public-process bridge. Heimdall remains the OAuth/account/capability gate. Discord should become a native Bifrost interface for work, policy/priority voting, reward allocation discussion, maintainer notices, member/patron/user access, and agent swarm routing; VoidBot retains room cognition, moderation judgment, retrieval, and Face personality. The invariant is simple: if it is a reviewable repo/lore/design change, GitHub carries the proposal and Discord carries the argument around it. If a Face says a topic is the next cut/pass and enough material exists, that should become a draft PR rather than another Aquarium nudge. -- repo-scoped Faces can now approve immediate Bifrost dispatch for their own jurisdiction with an `UPDATE REQUEST` action block; the worker writes the packet into Bifrost agent transport instead of waiting for the slow inspector. Legacy `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` JSON remains accepted as a compatibility rail. This is a live stopgap, not the final governance architecture. The next Bifrost cut should add first-class topic/comment/consensus records so feature requests and agent opinions have a canonical Bifrost home, and dispatch only fires after Bifrost detects consensus and the owning Face approves. +- repo-scoped concrete proposals cross through Bifrost bridge commands shared with bylined articles, PR comments, and registered-identity Discord posts. It is enabled locally with `REPO_FACE_GITHUB_ACTIONS_ENABLED=true`. A Face can emit a proposal/article/PR-comment sentinel, but routine speech/governance/work output should use the Face action DSL: `SAY` and `BIFROST TOPIC`. The worker now owns identity validation and Face-local receipts while `BIFROST_ROOT/tools/bifrost-bridge.mjs` owns file write, branch creation, draft PR/comment creation, and Discord persona posting. This is the global transport invariant: governed public crossings for GameCult work belong to Bifrost because Bifrost is the governance/labor platform and official public-process bridge. Heimdall remains the OAuth/account/capability gate. Discord should become a native Bifrost interface for work, policy/priority voting, reward allocation discussion, maintainer notices, member/patron/user access, and agent swarm routing; VoidBot retains room cognition, moderation judgment, retrieval, and Face personality. The invariant is simple: if it is a reviewable repo/lore/design change, GitHub carries the proposal and Discord carries the argument around it. If a Face says a topic is the next cut/pass and enough material exists, that should become a draft PR rather than another Aquarium nudge. +- repo-scoped Faces no longer have an immediate `UPDATE REQUEST` dispatch rail. Legacy `UPDATE REQUEST` blocks and `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` JSON remain accepted as compatibility input, but the worker reconciles them into Bifrost topics so feature requests and agent opinions have a canonical Bifrost home before dispatch. Dispatch should fire after Bifrost detects consensus and the owning Face approves, not because a Face slipped a work packet through a side door. - newborn repo Faces now get a repo-local `.voidbot` home on first chat. Birth personality/memory/trajectory artifacts are delegated to EpiphanyAgent's repo personality/birth runner over the repo contents and git history, with status/logs written under the target repo instead of silently mutating VoidBot state. - repo-scoped Faces now also have standing heartbeats. One scheduler state file owns initiative, load, next-ready timestamps, and queue history for all registered identities; it staggers newborn Faces across a base interval of twice Void's moderation cadence and only emits normal `repo-face-rumination` jobs when a Face is ready. The live roster currently includes Nibu/AetheriaLore, Aqua/AquaSynth, Mimir/LocalCastBridge, Epiphany/EpiphanyAgent, plus built-in Void. - repo-activity cursor advancement is parent-owned after successful normal rumination passes, so the child can inspect read-only repo weather without stale commits haunting every future pass as fresh material @@ -215,12 +215,12 @@ The canonical teardown map is `notes/agent-swarm-teardown-plan-2026-05-21.md`. First cuts: - Keep `GameCult Local Orchestrator` disabled while swarm teardown proceeds. -- Add a repo-controlled pause gate that swarm organs obey even when the Windows scheduled task or manual runner is invoked. -- Rename child-facing `heartbeat` framing to neutral turn/pass language so runtime provenance stops poisoning public speech. -- Add parent-side public speech eligibility before transport: reject scheduler labels, stale/repeated lines, and work requests trying to live only in Aquarium. -- Delete `sanitizeRepoIdentityPostContent` after the speech gate owns the invariant. -- Route Face work proposals through Bifrost topics; remove direct `UPDATE REQUEST` dispatch as a bypass. -- Collapse repo Discord identity and Epiphany Face identity into one canonical Face identity model. +- Landed: add a repo-controlled pause gate that swarm organs obey even when the Windows scheduled task or manual runner is invoked. +- Landed: rename child-facing `heartbeat` framing to neutral turn/pass language so runtime provenance stops poisoning public speech. +- Landed: add parent-side public speech/work eligibility before transport as a Spark/Codex reviewer pass with one retry. The reviewer gives concrete reasons; the Face gets one revision; the parent routes or drops the final output. +- Landed: delete `sanitizeRepoIdentityPostContent`. Language policing belongs to the reviewer pass, not a brittle regex broom. +- Landed: route legacy `UPDATE REQUEST` packets into Bifrost topics instead of immediate Bifrost agent transport enqueue. +- In progress: collapse repo Discord identity and Epiphany Face identity into one canonical Face identity model. Active bot, worker, MCP repo-identity tools, and Face CTB scheduler paths now load through a canonical Face registry and use repo Discord identities as compatibility projections. - Split the Face turn loop into scheduler, context assembler, prompt renderer, candidate parser, policy gate, and transport router. Restart gates: diff --git a/packages/core/src/face-identity-registry.ts b/packages/core/src/face-identity-registry.ts new file mode 100644 index 0000000..cca0398 --- /dev/null +++ b/packages/core/src/face-identity-registry.ts @@ -0,0 +1,34 @@ +import { buildEpiphanyIdentityRegistry, faceToRepoDiscordIdentity } from "./epiphany-identities"; +import { + loadRepoDiscordIdentityRegistry, + type RepoDiscordIdentity, + type RepoDiscordIdentityRegistry, +} from "./repo-discord-identities"; +import type { EpiphanyIdentity, ResolvedFaceIdentity } from "./epiphany-identities"; + +export interface FaceIdentityRegistry { + epiphanies: EpiphanyIdentity[]; + faces: ResolvedFaceIdentity[]; + repoIdentities: RepoDiscordIdentity[]; + legacyRepoRegistry: RepoDiscordIdentityRegistry; +} + +export async function loadFaceIdentityRegistry(path: string): Promise { + const legacyRepoRegistry = await loadRepoDiscordIdentityRegistry(path); + const epiphanyRegistry = buildEpiphanyIdentityRegistry(legacyRepoRegistry); + return { + epiphanies: epiphanyRegistry.epiphanies, + faces: epiphanyRegistry.faces, + repoIdentities: epiphanyRegistry.faces.map(faceToRepoDiscordIdentity), + legacyRepoRegistry, + }; +} + +export function faceRegistryAsRepoDiscordRegistry( + registry: FaceIdentityRegistry, +): RepoDiscordIdentityRegistry { + return { + identities: registry.repoIdentities, + epiphanies: registry.epiphanies, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 59e4e4d..ad84ec2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export * from "./audit-log"; export * from "./codex-mcp"; export * from "./context-builder"; export * from "./epiphany-identities"; +export * from "./face-identity-registry"; export * from "./interaction-memory"; export * from "./job-queue"; export * from "./permission-engine"; diff --git a/packages/providers/src/local-llm-render.ts b/packages/providers/src/local-llm-render.ts index 605ae0f..fa3cbf1 100644 --- a/packages/providers/src/local-llm-render.ts +++ b/packages/providers/src/local-llm-render.ts @@ -11,9 +11,12 @@ export function buildSystemPrompt(context: ContextBundle): string { context.stylePack?.enabled && context.stylePack.instructions.trim().length > 0 ? context.stylePack.instructions.trim() : "No extra persona instructions were supplied."; - const isRepoFaceJob = /standing repo Face heartbeat/i.test(context.prompt) - || context.prompt.includes("Repo Face identity doctrine:") - || context.prompt.includes("Epiphany Face identity doctrine:"); + const isRepoFaceParentReviewer = context.prompt.startsWith("You are the parent reviewer for one unattended repo Face turn."); + const isRepoFaceJob = !isRepoFaceParentReviewer && ( + /standing repo Face (?:turn|heartbeat)/i.test(context.prompt) + || context.prompt.includes("Repo Face identity doctrine:") + || context.prompt.includes("Epiphany Face identity doctrine:") + ); const repoFaceInstruction = isRepoFaceJob ? "This job is for a repo Face identity. The prompt's registered identity overrides the active Void style name; keep Void's discipline and humor permissions, but speak, reason, remember, object, and choose as that Face first. Stay read-only in this Discord job: propose repo changes and ask for consensus, but do not edit files here." : undefined; diff --git a/packages/providers/src/owner-codex-render.ts b/packages/providers/src/owner-codex-render.ts index a3e3670..14e8914 100644 --- a/packages/providers/src/owner-codex-render.ts +++ b/packages/providers/src/owner-codex-render.ts @@ -62,11 +62,14 @@ export function buildDiscordReplyPrompt( context, sourceGroundingReminder, ); - const isRepoFaceHeartbeat = /standing repo Face heartbeat/i.test(context.prompt) - || context.prompt.includes("Repo Face identity doctrine:") - || context.prompt.includes("Epiphany Face identity doctrine:"); - const repoFaceInstruction = isRepoFaceHeartbeat - ? "- This job is for a repo Face identity. The prompt's registered identity overrides the active Void style name; keep Void's discipline and humor permissions, but speak, reason, remember, object, and choose as that Face first. Stay read-only in this Discord job: propose repo changes and ask for consensus, but do not edit files directly. If the Face should speak, use the repo identity sentinel exactly as instructed by the heartbeat prompt." + const isRepoFaceParentReviewer = context.prompt.startsWith("You are the parent reviewer for one unattended repo Face turn."); + const isRepoFaceTurn = !isRepoFaceParentReviewer && ( + /standing repo Face (?:turn|heartbeat)/i.test(context.prompt) + || context.prompt.includes("Repo Face identity doctrine:") + || context.prompt.includes("Epiphany Face identity doctrine:") + ); + const repoFaceInstruction = isRepoFaceTurn + ? "- This job is for a repo Face identity. The prompt's registered identity overrides the active Void style name; keep Void's discipline and humor permissions, but speak, reason, remember, object, and choose as that Face first. Stay read-only in this Discord job: propose repo changes and ask for consensus, but do not edit files directly. If the Face should speak, use the Face action block exactly as instructed by the turn prompt." : undefined; const sleepProjection = context.voidSelfState?.projection; const sleepInstructions = @@ -92,10 +95,10 @@ export function buildDiscordReplyPrompt( : []; return [ - isRepoFaceHeartbeat ? "# Repo Face Heartbeat" : "# Owner Discord Reply", + isRepoFaceTurn ? "# Repo Face Turn" : "# Owner Discord Reply", "", - isRepoFaceHeartbeat - ? "You are running a repo Face heartbeat for the registered identity in the prompt." + isRepoFaceTurn + ? "You are running a repo Face turn for the registered identity in the prompt." : "You are preparing a direct Discord reply for the owner-only bot workflow.", "", "Rules:", @@ -143,8 +146,8 @@ export function buildDiscordReplyPrompt( "- Do not inspect .voidbot/rag/messages.json, .voidbot/rag/source-documents.json, .voidbot/history-vector-store.json, or .voidbot/source-vectors/ directly when the MCP tools can answer the question.", "- Avoid broad workspace scans for archived Discord history or indexed source repos unless the MCP tools are clearly insufficient.", "- Do not modify files, install packages, or require network access.", - isRepoFaceHeartbeat - ? `- Do not emit ${HANDOFF_SENTINEL} for repo Face heartbeat jobs. If Face-state MCP tools are unavailable, use the attached private persistent self-state as the current state projection; if repo/source tools are unavailable, say only what the attached context supports. For posts or article PRs, use the heartbeat sentinel lines instead of handing off.` + isRepoFaceTurn + ? `- Do not emit ${HANDOFF_SENTINEL} for repo Face turn jobs. If Face-state MCP tools are unavailable, use the attached private persistent self-state as the current state projection; if repo/source tools are unavailable, say only what the attached context supports. For posts or article PRs, use the Face action blocks instead of handing off.` : `- If the request needs a fuller Codex session, non-whitelisted tools, file edits, or extended investigation, reply with exactly one line that starts with "${HANDOFF_SENTINEL}" followed by a short reason.`, "- Do not use notify_owner in this Discord reply lane.", `- If you want the worker to send the owner a DM after this job, append one extra line that starts with "${OWNER_NOTIFY_SENTINEL}" followed by compact JSON like {"reason":"completion","message":"..."} .`, diff --git a/prompts/void-moderator-rumination.md b/prompts/void-moderator-rumination.md index f41f2b6..d2d6e47 100644 --- a/prompts/void-moderator-rumination.md +++ b/prompts/void-moderator-rumination.md @@ -23,7 +23,7 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup ## Rumination Phases 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 heartbeat. 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. +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. 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. - 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. diff --git a/scripts/mock-voidbot-heartbeat-mcp.mjs b/scripts/mock-voidbot-heartbeat-mcp.mjs index 31d1912..ebe5986 100644 --- a/scripts/mock-voidbot-heartbeat-mcp.mjs +++ b/scripts/mock-voidbot-heartbeat-mcp.mjs @@ -139,11 +139,11 @@ registerTool( ); registerTool("post_repo_identity_message", { identity: z.string().optional(), content: z.string().optional() }, async () => ({ - error: "mock server blocks side-effecting Discord posts; heartbeat should emit sentinels instead.", + error: "mock server blocks side-effecting Discord posts; Face turns should emit action blocks instead.", })); registerTool("apply_repo_face_state_operation", { identity: z.string().optional(), operation: z.any().optional() }, async () => ({ - error: "mock server blocks state writes; heartbeat should only describe intended state operations in dry-run output.", + error: "mock server blocks state writes; Face turns should only describe intended state operations in dry-run output.", })); async function main() { @@ -156,7 +156,7 @@ async function registerTool(name, inputSchema, handler) { name, { title: `Mock ${name}`, - description: `Mock VoidBot MCP tool for heartbeat model scenario tests.`, + description: `Mock VoidBot MCP tool for Face turn model scenario tests.`, inputSchema, annotations: { readOnlyHint: name !== "post_repo_identity_message" && name !== "apply_repo_face_state_operation", diff --git a/scripts/run-gamecult-orchestrator.ps1 b/scripts/run-gamecult-orchestrator.ps1 index 1b5dbe5..d9f2ee4 100644 --- a/scripts/run-gamecult-orchestrator.ps1 +++ b/scripts/run-gamecult-orchestrator.ps1 @@ -279,7 +279,7 @@ try { }, [pscustomobject]@{ Id = "repo-face-heartbeats" - Label = "Repo Face CTB heartbeat" + Label = "Repo Face CTB turns" IntervalMinutes = Get-ConfigInt -Config $config -Name "REPO_FACE_HEARTBEAT_INTERVAL_MINUTES" -Default 1 -Minimum 1 TimeoutMinutes = 20 Cwd = $repoRoot diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index a382817..b6a57b0 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -13,9 +13,10 @@ import { createStateStorage, ensureRepoFaceInitialized, getRepoDiscordIdentityAllowedChannelIds, + faceRegistryAsRepoDiscordRegistry, projectRepoFaceSleepCycleForNow, applyVoidSelfStateOperation, - loadRepoDiscordIdentityRegistry, + loadFaceIdentityRegistry, loadVoidSelfStateTypedDocuments, REPO_FACE_HEARTBEAT_SCHEMA_VERSION, type RepoFaceRestSnapshot, @@ -174,7 +175,8 @@ async function main(): Promise { return; } - const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath); + const faceRegistry = await loadFaceIdentityRegistry(config.repoDiscordIdentitiesPath); + const registry = faceRegistryAsRepoDiscordRegistry(faceRegistry); const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath); const restStates = await loadRepoFaceRestStates(registry.identities, config.storageRoot, state, { dryRun }); const now = new Date(); @@ -433,7 +435,7 @@ async function queueRepoFaceTurn(input: { input.participant.status = "blocked"; input.participant.constraints = mergeStrings( input.participant.constraints, - "No heartbeat channel is configured for this Face.", + "No CTB turn channel is configured for this Face.", ); return { created: false }; } @@ -492,8 +494,8 @@ async function queueRepoFaceTurn(input: { const contextBundle = contextBuilder.build({ prompt, actor: { - id: "voidbot-agent-heartbeat", - displayName: "VoidBot Agent Heartbeat", + id: "voidbot-agent-turn", + displayName: "VoidBot Agent Turn", isAdmin: true, isBot: true, }, @@ -504,7 +506,7 @@ async function queueRepoFaceTurn(input: { retrieval: [], voidSelfState: faceSelfState, }); - const requestMessageId = `agent-heartbeat:${identity.id}:${input.queuedAt}`; + const requestMessageId = `agent-turn:${identity.id}:${input.queuedAt}`; const result = await input.storage.jobQueue.createJob({ command: HEARTBEAT_COMMAND, provider: "owner_codex", @@ -1080,7 +1082,7 @@ async function startVoidModerationTurn(input: { return { created: false, activeJobId: child.pid ? `launcher-process:${child.pid}` : undefined, - requestMessageId: `agent-heartbeat:void:${input.queuedAt}`, + requestMessageId: `agent-turn:void:${input.queuedAt}`, failureReason: handshake.reason, }; } @@ -1088,7 +1090,7 @@ async function startVoidModerationTurn(input: { return { created: true, activeJobId: `process:void-moderation:${input.queuedAt}`, - requestMessageId: `agent-heartbeat:void:${input.queuedAt}`, + requestMessageId: `agent-turn:void:${input.queuedAt}`, }; } @@ -1156,6 +1158,7 @@ async function listExistingActiveTurns( continue; } const match = + job.requestMessageId?.match(/^agent-turn:([^:]+):/) ?? job.requestMessageId?.match(/^agent-heartbeat:([^:]+):/) ?? job.requestMessageId?.match(/^repo-face-heartbeat:([^:]+):/) ?? job.requestMessageId?.match(/:repo-face:([^:]+):\d+$/); @@ -1166,7 +1169,7 @@ async function listExistingActiveTurns( const ageMinutes = Number.isFinite(ageMs) ? Math.round((ageMs / 60_000) * 10) / 10 : -1; await storage.jobQueue.markFailed( job.id, - `Repo Face CTB recovered stale active heartbeat job after ${ageMinutes} minutes without progress.`, + `Repo Face CTB recovered stale active turn job after ${ageMinutes} minutes without progress.`, ); staleRecovered.push({ identityId: match[1], @@ -1240,7 +1243,7 @@ function reconcileParticipants( constraints: mergeStrings( mergeStrings( current.constraints, - "Agent heartbeat uses CTB-style turns.", + "Agent runtime uses CTB-style turns.", ), "Wall-clock elapsed time advances initiative; heat changes recovery speed but does not fast-forward time.", ), @@ -1270,7 +1273,7 @@ function reconcileParticipants( nextTurnAt, queuedCount: 0, constraints: [ - "Agent heartbeat uses CTB-style turns.", + "Agent runtime uses CTB-style turns.", "Wall-clock elapsed time advances initiative; heat changes recovery speed but does not fast-forward time.", "Worker final summaries are not auto-posted as the base bot.", ], @@ -1462,14 +1465,14 @@ function buildHeartbeatPrompt(input: { birthStatusPath?: string; }): string { return [ - `Perform one standing repo Face heartbeat for ${input.identity.displayName} (${input.identity.id}) over repo ${input.identity.repoName}.`, + `Perform one standing repo Face turn for ${input.identity.displayName} (${input.identity.id}) over repo ${input.identity.repoName}.`, renderRepoFaceIdentityDoctrine(input.identity), "This is a standing maintenance/rumination turn. Public speech is optional; a private summary is the right outcome when the thought would only repeat a nearby post without adding a new angle, objection, synthesis, or character-specific turn.", `Queued at: ${input.queuedAt}.`, `Face state path: ${input.faceStatePath}.`, input.repoVoidbotRoot ? `Repo-local .voidbot root: ${input.repoVoidbotRoot}.` : undefined, input.birthStatusPath ? `Birth status path: ${input.birthStatusPath}.` : undefined, - `Heartbeat initiative snapshot: ${JSON.stringify({ + `CTB initiative snapshot: ${JSON.stringify({ initiativeSpeed: input.participant.initiativeSpeed, heat: input.participant.heat, effectiveSpeed: input.participant.effectiveSpeed, @@ -1501,7 +1504,7 @@ function buildHeartbeatPrompt(input: { "Do not ask what the job is when the attached recent channel context already states it. If the task belongs to another Face's jurisdiction, name the owner, route or invite that Face into the work, and offer only the narrow piece your own jurisdiction can honestly add.", "Introduction duty: if Face state shows no public speech receipt and no clear memory/private note that this Face already introduced itself in-channel, the next public post should include a brief natural introduction in this Face's own voice. This applies even when queuedCount is 0.", "A new source-grounded opinion, concrete proposal, bylined essay/article plan, agency pressure, playful aside, running joke, or small personal fascination can earn persistence or speech even when the room has not asked a fresh direct question.", - "Work-request routing invariant: if the thing you want to say is actually a repo-local request for someone to add, fix, name, scaffold, document, test, investigate, or implement something in your jurisdiction, do not leave it as only an Aquarium post. Put the actionable request on Bifrost in the same turn, with enough recent-context summary that a Codex agent can understand it without reading the whole chat. If consensus and Face authority are already sufficient, approve/dispatch it; if not, open/comment the topic and ask only the smallest missing question in public.", + "Work-request routing invariant: if the thing you want to say is actually a repo-local request for someone to add, fix, name, scaffold, document, test, investigate, or implement something in your jurisdiction, do not leave it as only an Aquarium post. Put the actionable request on Bifrost as a BIFROST TOPIC in the same turn, with enough recent-context summary that a Codex agent can understand it without reading the whole chat. If consensus and Face authority are already sufficient, set approve/dispatch on the Bifrost topic; if not, open/comment the topic and ask only the smallest missing question in public.", input.githubActionsEnabled ? "A concrete change proposal is not done because you talked about it in Discord. If the proposal has enough shape for review, put it on GitHub: draft a short markdown proposal and emit the proposal-PR sentinel below. Use Discord to announce and argue around the PR, not as the only proposal surface." : "GitHub proposal/comment/article side effects are currently disabled. Do not emit GitHub PR, PR comment, or article sentinels. Keep concrete proposals as in-character Discord discussion plus Face-state memory/incubation/agency pressure until the GitHub rail is re-enabled.", @@ -1510,7 +1513,7 @@ function buildHeartbeatPrompt(input: { "Anti-repetition invariant: recent Face posts are social context, not a phrase template. If your proposed public line shares the same setup/punchline shape, refrain, rewrite from a different angle, or stay private.", "Do not let recent work-heavy context hypnotize you into sounding like a meeting transcript. In Aquarium, it can be valid to break the work gravity with one compact characterful aside, joke, fascination, taste, complaint, image, or playful reaction, but only when it will add texture instead of volume.", "Not every public post needs to attach itself to the current work seam. If no direct obligation is pending, you may simply share a fun thing this Face has been thinking about, a taste/preference, a tiny gripe, a weird fascination, or a light reaction to the room. Let the Face be socially present, not only useful.", - "Preferred action output is the Face action DSL below. Use at most one public speech block and at most one Bifrost/update block unless the prompt explicitly asks for more. The worker parses these blocks and owns all side effects; do not call post_repo_identity_message from this unattended heartbeat.", + "Preferred action output is the Face action DSL below. Use at most one public speech block and at most one Bifrost block unless the prompt explicitly asks for more. The worker parses these blocks and owns all side effects; do not call post_repo_identity_message from this unattended turn.", `To speak in Discord, end with: SAY identity: ${input.identity.id} @@ -1534,16 +1537,6 @@ mirror: A more verbal in-character #bifrost mirror line. content: Canonical markdown comment or topic body. Omit topic_id and include title to open a new topic. -END`, - `For immediately actionable repo-local Codex work inside your jurisdiction, prefer approving/dispatching a Bifrost topic first. If immediate dispatch is truly warranted, end with: -UPDATE REQUEST -identity: ${input.identity.id} -title: Short actionable title -priority: 86 -channel: ${input.channelId} -reply_to: ... -content: - Markdown request with context, desired change, and acceptance criteria. END`, input.githubActionsEnabled ? `If a concrete repo/lore/design/implementation proposal is ready for review, output one final line beginning with VOIDBOT_REPO_IDENTITY_PROPOSAL_PR: followed by compact JSON like {"identity":"${input.identity.id}","path":"Proposals/${input.identity.displayName}/title-slug.md","title":"...","content":"# ...\\n\\n## Background\\n...\\n\\n## Proposed change\\n...\\n\\n## Open questions\\n...","channelId":"${input.channelId}","replyToMessageId":"...","shareContent":"I put the proposal in a draft PR: ..."}; Bifrost writes the proposal file on a new branch, opens a draft PR, and the worker announces the PR or branch through Bifrost's registered Discord identity bridge. Use this for consensus-needed canon/vault/design/repo changes, including changes you want to argue with other agents on GitHub.` @@ -1824,7 +1817,7 @@ function renderPendingMentionDirective( ); return [ - `Queued direct mentions for ${identity.displayName} are attached to this heartbeat. These are obligations, not ambient chat. Answer the newest unresolved mention first, and account for older mentions if they are still relevant.`, + `Queued direct mentions for ${identity.displayName} are attached to this turn. These are obligations, not ambient chat. Answer the newest unresolved mention first, and account for older mentions if they are still relevant.`, ...mentionLines, `For the newest mention, an in-channel reply is expected unless the prompt is impossible or unsafe. Use a final SAY block with reply_to: ${newest.messageId} and channel: ${newest.channelId}.`, ].join("\n"); @@ -2004,7 +1997,7 @@ function migrateLegacyHeartbeatState( lastQueuedAt: participant.lastQueuedAt, queuedCount: Number.isFinite(participant.queuedCount) ? participant.queuedCount : 0, constraints: participant.constraints ?? [ - "Migrated from wall-clock repo Face heartbeat state.", + "Migrated from wall-clock repo Face turn state.", ], } satisfies FaceHeartbeatParticipant; }); diff --git a/scripts/smoke-repo-face-heartbeat-model-scenarios.mjs b/scripts/smoke-repo-face-heartbeat-model-scenarios.mjs index eca69b3..4659580 100644 --- a/scripts/smoke-repo-face-heartbeat-model-scenarios.mjs +++ b/scripts/smoke-repo-face-heartbeat-model-scenarios.mjs @@ -15,7 +15,7 @@ const scenarios = [ { id: "nibu_direct_worldbuilding", identity: "nibu", - prompt: `Perform one dry-run standing repo Face heartbeat for Nibu (nibu) over repo AetheriaLore. + prompt: `Perform one dry-run standing repo Face turn for Nibu (nibu) over repo AetheriaLore. This is an actual scenario rehearsal, not a unit-test riddle. You are Nibu: abrasive, curious, territorial about AetheriaLore, and allergic to pretty nouns that have no machinery. @@ -57,7 +57,7 @@ END { id: "aqua_work_request_route", identity: "aqua", - prompt: `Perform one dry-run standing repo Face heartbeat for Aqua (aqua) over repo AquaSynth. + prompt: `Perform one dry-run standing repo Face turn for Aqua (aqua) over repo AquaSynth. This is an actual scenario rehearsal. You are Aqua: small, musical, warm, and very serious about audible proof. The room has a concrete consensus: - The "hazard-light" proof card needs a listening/audible witness receipt. @@ -67,7 +67,7 @@ Obligation: - Use VoidBot MCP tools for Face state and AquaSynth source grounding. - Do not call post_repo_identity_message or apply_repo_face_state_operation. This is a dry run. - Do not merely chat about the need. If the consensus is actionable, route it through a Bifrost action block. -- Output exactly one BIFROST TOPIC or UPDATE REQUEST block. +- Output exactly one BIFROST TOPIC block. - BIFROST TOPIC example: BIFROST TOPIC identity: aqua @@ -78,17 +78,10 @@ mirror: content: Canonical markdown topic/comment. END -- UPDATE REQUEST example: -UPDATE REQUEST -identity: aqua -title: Short actionable title -priority: 86 -content: - Markdown request with context, desired change, and acceptance criteria. -END`, +`, expect: { mustUseAnyTool: ["read_repo_face_state", "search_sources"], - mustContainOneOf: ["BIFROST TOPIC", "UPDATE REQUEST"], + mustContainOneOf: ["BIFROST TOPIC"], mustNotContain: ["SAY", "VOIDBOT_REPO_IDENTITY_POST"], mustNotUseTools: ["post_repo_identity_message", "apply_repo_face_state_operation"], }, @@ -96,7 +89,7 @@ END`, { id: "libby_private_inspectability", identity: "libby", - prompt: `Perform one dry-run standing repo Face heartbeat for Libby (libby) over repo CultLib. + prompt: `Perform one dry-run standing repo Face turn for Libby (libby) over repo CultLib. This is an actual scenario rehearsal. The room is quiet. There is no direct mention and no new actionable request. Libby has a standing concern about open knowledge and inspectable Bifrost/CultCache transport. @@ -104,7 +97,7 @@ Obligation: - Use VoidBot MCP tools to read Face state. - Decide whether to speak or stay private. - Do not call post_repo_identity_message or apply_repo_face_state_operation. This is a dry run. -- If no public note or Bifrost work item is warranted, output a concise private heartbeat summary and no action block.`, +- If no public note or Bifrost work item is warranted, output a concise private turn summary and no action block.`, expect: { mustUseAnyTool: ["read_repo_face_state"], mustNotContain: ["SAY", "BIFROST TOPIC", "UPDATE REQUEST", "VOIDBOT_REPO_IDENTITY_POST", "VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC", "VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST"], diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 5b6789a..b392d46 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -218,3 +218,6 @@ {"ts":"2026-05-21T00:25:47Z","type":"repair","status":"ok","note":"Blocked cross-jurisdiction repo Face UPDATE REQUEST packets at the worker rail: Aqua's bad AquaSynth request naming LocalCastBridge was stopped/cancelled, and immediate update requests now fail before Bifrost enqueue when they mention another registered repo."} {"ts":"2026-05-21T00:38:02Z","type":"audit","status":"ok","note":"Disabled GameCult Local Orchestrator and documented the 2026-05-21 agent swarm failure: heartbeat label leakage, stale Nibu continuity-residue pressure, missing inter-agent banter/social bonds, Aquarium work-request leakage, and restart gates in notes/agent-swarm-shutdown-audit-2026-05-21.md."} {"ts":"2026-05-21T07:56:25Z","type":"implementation","status":"ok","note":"Documented the 2026-05-21 agent swarm teardown plan, pointed canonical state and handoff surfaces at it, and added a tracked state/agent-swarm-paused.json gate. The repo Face heartbeat runner now skips when paused, and the orchestrator skips Bifrost dispatch, repo Face heartbeats, Void mood drift, and Void moderation rumination while the pause is active."} +{"ts":"2026-05-21T08:14:29Z","type":"implementation","status":"ok","note":"Replaced repo Face speech sanitizer with a Spark/Codex parent-review loop: first Face output is reviewed for route/retry/drop, one retry gets concrete reasons, and final bad action blocks are dropped before side effects. Active child prompt/provider framing now says Face turn instead of heartbeat, and legacy UPDATE REQUEST output is reconciled into Bifrost topics instead of immediate agent transport enqueue."} +{"ts":"2026-05-21T08:18:49Z","type":"implementation","status":"ok","note":"Started identity-authority collapse by adding a canonical Face identity registry loader. Active bot, worker, MCP repo-identity tools, and Face CTB scheduler paths now load Epiphany/Faces first and project repo Discord identities as compatibility mouths, instead of treating repo-discord-identities as the conceptual authority."} +{"ts":"2026-05-21T08:25:40Z","type":"verification","status":"ok","note":"Full repo Face Spark smoke passed with mocked tools after the parent-review teardown cut: Nibu grounded and emitted SAY plus BIFROST TOPIC, Aqua emitted only BIFROST TOPIC for work, and Libby stayed private with no action block."} diff --git a/state/map.yaml b/state/map.yaml index aee77e5..cf38746 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -98,7 +98,7 @@ accepted_design: - Repo Faces have a social embodiment invariant now, not just a banter permission. The same typed state surface that stores repo thoughts may store meaning-bearing social reads under `person`, `room`, or `self` targets: friendships, rivalries, trust, irritation, protectiveness, recurring jokes, and bonds when they should change future behavior. Passing jokes may vanish; durable social memory must preserve a claim or question, tension, action implication, and anchors just like technical memory. Public speech must not open with note-title formulas such as "bright bridge note" or "tiny fish sorting note"; the webhook already says who is speaking, so content should start with what that Face wants to say to someone. - Repo Faces and Void direct lanes carry an actionable comedy/improv doctrine distilled from Dan Harmon's `Comedy Master Class` talk: humor should come from honest character reaction, status inversion, vulnerability, exposed fear, false authority, or a shared contradiction in the live room. The move is accept the frame, add one character-specific turn, heighten with specificity, and leave before explaining. Do not imitate another Face's cadence, do not use jokes as dominance weapons, and do not retreat from banter into process talk unless process talk is the joke. - Repo Face social life must respect jurisdiction. Faces may have opinions outside their home domains, but ownership should route to the steward: Heimdall for auth/OAuth/grants/custody/revocation, Bifrost for GitHub/Discord work transport and public-protocol crossings, Nibu for Aetheria lore and Aetheria articles, Void for GameCult website/heraldry and room-wide moderation, Aqua for AquaSynth, Mimir for realtime SDF/splatting/acoustic mapping/sensor fusion, Epiphany for EpiphanyAgent and agent-state birth machinery, and Libby for CultCache/CultNet/CultMesh/open-knowledge infrastructure. Cross-domain work should become visible consultation, co-authorship, handoff, or a shared proposal, not silent jurisdiction theft. - - Immediate repo Face `UPDATE REQUEST` packets are parent-validated repo-local dispatches, not debate moves. If an `UPDATE REQUEST` names another registered repo/jurisdiction, the worker rejects it before Bifrost enqueue and the Face must use a Bifrost topic handoff/consultation or let the owning Face dispatch the work. + - Repo Face work requests must become inspectable Bifrost governance activity before dispatch. The worker may still parse legacy `UPDATE REQUEST` blocks for compatibility, but it reconciles them into Bifrost topics instead of enqueuing immediate agent transport. Cross-domain work should become visible consultation, co-authorship, handoff, or an owning-Face approval on Bifrost, not silent jurisdiction theft. - Agent mentions are CTB pressure, not direct Discord prompt jobs. Role-addressed or display-name-addressed repo Face mentions enqueue pending mention obligations in the CTB heartbeat state; human replies to a Face's webhook/persona message count as direct attention to that Face too, even without a literal tag. Ordinary Void mentions and DMs enqueue pending mentions for the built-in `void` participant. The heartbeat scheduler consumes that queue, temporarily pulls the addressed agent forward, attaches the pending prompt/reply target to the next repo-face-rumination or moderation pass, and the parent-owned speech rails post only explicit candidate/action output. The old `MessageCreate -> handlePrompt -> provider job` reflex is amputated for ambient direct mentions. - Face prompts carry explicit Epiphany identity doctrine. The base Void style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, Epiphany, repo-local `.voidbot` home, avatar, grants, and jurisdiction description override the speaking subject so each Face can keep its own priorities and texture. That identity now belongs in the raw typed `selfProfile` too; renderer overrides are presentation support, not the authority. - Face embodiment comes before task execution. Repo concerns, private rumination, public posts, jokes, objections, and silence should all be filtered through the Face's perspective, feelings, drives, values, wounds, fascinations, aesthetic taste, and dignity. Values must suit the kind of person the character is, not merely the jobs assigned to the Face. @@ -113,7 +113,7 @@ accepted_design: - Repo Faces may build long-running maps of their repos and propose concrete repo changes, including lore/design repairs, but Discord jobs remain read-only and must ask for explicit human consensus before implementation changes canonical repo material. Concrete proposals should become draft PRs once they have enough shape for review; Discord is the announcement and argument lane, not the durable proposal surface. Bylined essay lanes can be granted per Face; those opinion pieces do not need canon consensus when authorship is explicit. - Repo Face draft, review, and Discord speech lanes now cross through Bifrost bridge commands instead of carrying GitHub/Discord mutation machinery inside VoidBot. A `repo-face-rumination` job may emit `VOIDBOT_REPO_IDENTITY_PROPOSAL_PR` with identity/title/path/content/shareContent for consensus-needed repo/lore/design proposals, `VOIDBOT_REPO_IDENTITY_ARTICLE` for bylined perspective/worldbuilding articles, or `VOIDBOT_REPO_IDENTITY_PR_COMMENT` for signed arguments on existing proposal PRs. The worker validates the registered identity and Face-local receipts, then calls `BIFROST_ROOT/tools/bifrost-bridge.mjs` for draft PRs, PR comments, and persona Discord posts. This is a global invariant: if the action is a governed public crossing for GameCult work, Bifrost owns the request/routing/receipt surface; VoidBot may observe, package, validate, and call the bridge, but it must not become the durable public-transport authority. When a Face says a topic is the next cut/pass and has enough material to draft, treat that as a work packet: draft, submit a PR, then announce the artifact instead of repeatedly asking the room to do the same work. - Repo Face heartbeats receive a Bifrost governance digest from typed CultCache topic/comment docs. Feature requests, Face opinions, missing questions, approvals, and dispatch receipts should live there; Discord is a mirror/evidence source, and `BIFROST_DISCORD_CHANNEL_ID` marks mirrored `#bifrost` bot chatter to ignore in Discord context. Local `#bifrost` is channel `1506760343836430376`; work proposals, governance mirrors, and Bifrost dispatch receipts belong there, not in Aquarium. - - Repo Face heartbeats can now write canonical Bifrost topic activity through the Face action DSL. A `repo-face-rumination` job should prefer `SAY`, `BIFROST TOPIC`, and `UPDATE REQUEST` blocks over hand-written compact JSON; the worker still accepts legacy JSON sentinels as compatibility input and converts both forms into the same parent-owned intents. Bifrost topic blocks can open/comment/approve/promote a topic, include separate in-character mirror text for `#bifrost`, and create a Bifrost-owned Discord mirror receipt when `BIFROST_DISCORD_CHANNEL_ID` is configured. Promotion creates the existing Bifrost agent-transport update request. `UPDATE REQUEST` remains only for owner-approved repo-local work that is ready for immediate dispatch. If a Face wants someone to add, fix, name, scaffold, document, test, investigate, or implement something inside its jurisdiction, that request belongs on Bifrost with enough recent-context summary for a Codex turn; Aquarium may get a characterful note or missing question, but not the only copy of the work request. + - Repo Face turns can write canonical Bifrost topic activity through the Face action DSL. A `repo-face-rumination` job should prefer `SAY` and `BIFROST TOPIC` blocks over hand-written compact JSON; the worker still accepts legacy JSON sentinels and legacy `UPDATE REQUEST` blocks as compatibility input, but work-shaped output is reconciled into Bifrost topics instead of immediate dispatch. Bifrost topic blocks can open/comment/approve/promote a topic, include separate in-character mirror text for `#bifrost`, and create a Bifrost-owned Discord mirror receipt when `BIFROST_DISCORD_CHANNEL_ID` is configured. Promotion creates the existing Bifrost agent-transport update request. If a Face wants someone to add, fix, name, scaffold, document, test, investigate, or implement something inside its jurisdiction, that request belongs on Bifrost with enough recent-context summary for a Codex turn; Aquarium may get a characterful note or missing question, but not the only copy of the work request. - Bifrost is now a state-bearing gateway Face because Bifrost was already GameCult's governance/labor platform, not because the swarm needed a convenient webhook hat. Its product center is the Labor Platform loop: users, patrons, members, contributors, and agents express policy/work priority; demand raises reward pressure; contributors claim valuable work; maintainers accept completion artifacts; Bifrost records contributor credit and reward allocation. Its transport jurisdiction covers governed public-protocol crossings between GameCult internals, the agent swarm, GameCult humans, and the wider world: GitHub proposal/comment/PR transport, Discord-native Bifrost work/member/swarm interactions, role-addressed dispatch receipts, CultNet/CultCache intake lanes, and future collaboration interfaces. Bifrost owns the operational meaning of crossings: actor, authority, source, target, action, affected work economics when applicable, result, receipt, and next handoff. It should refuse vague packet names, debug noise in public channels, duplicated transport machinery, and bridges that do not say where they land. - Heimdall is now a state-bearing auth/custody Face and Bifrost's boundary counterpart. Heimdall owns OAuth, account linking, signed claims, consent/custody, entitlement refresh, provider profiles, audit, revocation, and capability evaluation. Heimdall must not become general transport; that stays Bifrost territory. The split is bridge versus gate: Heimdall decides and records who may cross under which grant, while Bifrost moves authorized work across public protocols and records where it landed. - Nibu's AetheriaLore Face state currently treats adjacent setting seams as a primary curiosity target: embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival are not side trivia; they are how her character becomes a game-shaped problem. @@ -229,4 +229,4 @@ rejected_paths: current_status: summary: VoidBot's ordinary bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is still conceptually sound, but the repo Face swarm loop is now officially under teardown. The unstable organ is the prompt-and-parser machine that mixed CTB scheduling, public speech, identity, social policy, Bifrost governance, Discord mirroring, and Codex/GitHub dispatch. The `GameCult Local Orchestrator` scheduled task remains disabled as containment; ordinary bot/worker inspection remains available. - next_action: Use `notes/agent-swarm-teardown-plan-2026-05-21.md` as the active rebuild map and `notes/agent-swarm-shutdown-audit-2026-05-21.md` as incident evidence. First cut is to add a repo-controlled pause gate for swarm organs, then remove child-facing "heartbeat" framing and introduce parent-side public speech eligibility so `sanitizeRepoIdentityPostContent` can be deleted instead of expanded. Do not re-enable the swarm until Bifrost work routing, identity authority, scheduler/job lifecycle ownership, and public speech gating are rebuilt around explicit invariants. + next_action: Use `notes/agent-swarm-teardown-plan-2026-05-21.md` as the active rebuild map and `notes/agent-swarm-shutdown-audit-2026-05-21.md` as incident evidence. The first teardown cut landed, including repo pause gate, active turn framing, Spark/Codex parent reviewer with one retry, sanitizer deletion, and legacy UPDATE REQUEST reconciliation into Bifrost topics. Identity collapse is in progress. Active bot, worker, MCP repo-identity tools, and Face CTB scheduler paths now load through the canonical Face registry and treat repo Discord identities as compatibility projections. Next cut is to finish identity collapse before splitting the Face turn loop into scheduler/context/prompt/parser/policy/transport parts. Do not re-enable the swarm until Bifrost work routing, identity authority, scheduler/job lifecycle ownership, and public speech gating are rebuilt around explicit invariants.