Route Face governance through Bifrost topics

This commit is contained in:
Meta Vens 2026-05-20 21:45:56 +01:00
commit 4893eeacad
7 changed files with 370 additions and 9 deletions

View file

@ -48,6 +48,7 @@ const REPO_IDENTITY_ARTICLE_SENTINEL = "VOIDBOT_REPO_IDENTITY_ARTICLE:";
const REPO_IDENTITY_PROPOSAL_PR_SENTINEL = "VOIDBOT_REPO_IDENTITY_PROPOSAL_PR:";
const REPO_IDENTITY_PR_COMMENT_SENTINEL = "VOIDBOT_REPO_IDENTITY_PR_COMMENT:";
const REPO_IDENTITY_UPDATE_REQUEST_SENTINEL = "VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST:";
const REPO_IDENTITY_BIFROST_TOPIC_SENTINEL = "VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC:";
const embedder = createTextEmbedder({
backend: config.ragEmbeddingBackend,
@ -336,6 +337,7 @@ async function processJob(job: JobRecord): Promise<void> {
if (job.command === "repo-face-rumination") {
const repoIdentityPosts = parseRepoIdentityPostIntents(finalResponse);
const repoIdentityBifrostTopics = parseRepoIdentityBifrostTopicIntents(finalResponse);
const repoIdentityUpdateRequests = parseRepoIdentityUpdateRequestIntents(finalResponse);
const repoIdentityArticles = config.repoFaceGithubActionsEnabled
? parseRepoIdentityArticleIntents(finalResponse)
@ -353,18 +355,32 @@ async function processJob(job: JobRecord): Promise<void> {
const prCommentSubmitted = !proposalPrSubmitted && repoIdentityPrComments.length > 0;
const articlePrSubmitted =
!proposalPrSubmitted && !prCommentSubmitted && repoIdentityArticles.length > 0;
const bifrostTopicSubmitted =
!proposalPrSubmitted && !prCommentSubmitted && !articlePrSubmitted && repoIdentityBifrostTopics.length > 0;
const updateRequestSubmitted =
!proposalPrSubmitted && !prCommentSubmitted && !articlePrSubmitted && repoIdentityUpdateRequests.length > 0;
!proposalPrSubmitted &&
!prCommentSubmitted &&
!articlePrSubmitted &&
!bifrostTopicSubmitted &&
repoIdentityUpdateRequests.length > 0;
if (repoIdentityProposals.length > 0) {
await writeRepoIdentityProposalPrIntent(job, repoIdentityProposals[0]);
} else if (repoIdentityPrComments.length > 0) {
await commentRepoIdentityPullRequestIntent(job, repoIdentityPrComments[0]);
} else if (repoIdentityArticles.length > 0) {
await writeRepoIdentityArticleIntent(job, repoIdentityArticles[0]);
} else if (repoIdentityBifrostTopics.length > 0) {
await submitRepoIdentityBifrostTopicIntent(job, repoIdentityBifrostTopics[0]);
} else if (repoIdentityUpdateRequests.length > 0) {
await enqueueRepoIdentityUpdateRequestIntent(job, repoIdentityUpdateRequests[0]);
}
if (!proposalPrSubmitted && !prCommentSubmitted && !articlePrSubmitted && !updateRequestSubmitted) {
if (
!proposalPrSubmitted &&
!prCommentSubmitted &&
!articlePrSubmitted &&
!bifrostTopicSubmitted &&
!updateRequestSubmitted
) {
for (const post of repoIdentityPosts.slice(0, 1)) {
await postRepoIdentityIntent(job, post);
}
@ -384,10 +400,12 @@ async function processJob(job: JobRecord): Promise<void> {
proposalPrSubmitted ||
prCommentSubmitted ||
articlePrSubmitted ||
bifrostTopicSubmitted ||
updateRequestSubmitted,
articlePrSubmitted,
proposalPrSubmitted,
prCommentSubmitted,
bifrostTopicSubmitted,
updateRequestSubmitted,
ignoredGithubActionIntents,
reason:
@ -397,6 +415,8 @@ async function processJob(job: JobRecord): Promise<void> {
? "repo_face_rumination_commented_on_pr_as_registered_identity"
: articlePrSubmitted
? "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
@ -611,6 +631,20 @@ interface RepoIdentityUpdateRequestIntent {
replyToMessageId?: string;
}
interface RepoIdentityBifrostTopicIntent {
identity?: string;
topicId?: string;
title?: string;
content: string;
stance?: string;
priority?: number;
approve?: boolean;
dispatch?: boolean;
sourceMessageIds: string[];
channelId?: string;
replyToMessageId?: string;
}
interface BifrostBridgeReceipt {
action?: string;
ok?: boolean;
@ -629,6 +663,15 @@ interface BifrostUpdateRequestReceipt {
priority: number;
}
interface BifrostGovernanceTopicReceipt {
id: string;
status: string;
title: string;
jurisdictionRepoName: string;
jurisdictionAgentIdentity?: string;
dispatchRequestId?: string;
}
async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIntent): Promise<void> {
if (!config.botToken) {
throw new Error("DISCORD_BOT_TOKEN is required for Bifrost to post repo identity responses.");
@ -936,6 +979,102 @@ async function enqueueRepoIdentityUpdateRequestIntent(
);
}
async function submitRepoIdentityBifrostTopicIntent(
job: JobRecord,
intent: RepoIdentityBifrostTopicIntent,
): Promise<void> {
const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt);
if (!identityId) {
throw new Error(`Could not resolve repo identity for Bifrost topic 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 Bifrost topic intent on job ${job.id}.`);
}
const contentFile = await writeBifrostPayloadFile(
job,
`${identity.id}-bifrost-topic.md`,
ensureTrailingNewline(intent.content),
);
const sourceMessageIds = Array.from(new Set([
...intent.sourceMessageIds,
intent.replyToMessageId,
].filter((value): value is string => typeof value === "string" && value.trim().length > 0)));
let topicId = intent.topicId;
if (!topicId) {
if (!intent.title) {
throw new Error(`Bifrost topic intent for ${identity.id} on job ${job.id} needs title when topicId is absent.`);
}
const opened = runBifrostGovernanceThreads([
"open",
"--repo",
identity.repoName,
"--agent",
identity.id,
"--title",
intent.title,
"--summary-file",
contentFile,
"--priority",
String(normalizeBifrostPriority(intent.priority)),
"--source-kind",
"repo_face_heartbeat",
"--created-by",
identity.id,
...(intent.channelId ?? job.outputChannelId ? ["--source-channel-id", intent.channelId ?? job.outputChannelId] : []),
...(sourceMessageIds.length > 0 ? ["--source-message-ids", sourceMessageIds.join(",")] : []),
]) as BifrostGovernanceTopicReceipt;
topicId = opened.id;
} else if (!intent.approve) {
runBifrostGovernanceThreads([
"comment",
"--topic",
topicId,
"--author",
identity.id,
"--author-kind",
"face",
"--stance",
normalizeBifrostTopicStance(intent.stance),
"--body-file",
contentFile,
...(intent.replyToMessageId ? ["--source-message-id", intent.replyToMessageId] : []),
]);
}
if (intent.approve) {
runBifrostGovernanceThreads([
"approve",
"--topic",
topicId,
"--approved-by",
identity.id,
"--body-file",
contentFile,
...(intent.replyToMessageId ? ["--source-message-id", intent.replyToMessageId] : []),
]);
}
let promoted: { topic?: BifrostGovernanceTopicReceipt; request?: BifrostUpdateRequestReceipt } | undefined;
if (intent.dispatch) {
promoted = runBifrostGovernanceThreads(["promote", "--topic", topicId]) as {
topic?: BifrostGovernanceTopicReceipt;
request?: BifrostUpdateRequestReceipt;
};
}
console.log(
promoted?.request
? `Promoted Bifrost topic ${topicId} to update request ${promoted.request.id} for ${identity.id}/${identity.repoName} from job ${job.id}.`
: `Updated Bifrost topic ${topicId} for ${identity.id}/${identity.repoName} from job ${job.id}.`,
);
}
async function writeBifrostPayloadFile(job: JobRecord, fileName: string, content: string): Promise<string> {
const directory = resolve(config.storageRoot, "artifacts", job.id, "bifrost-bridge");
await mkdir(directory, { recursive: true });
@ -963,6 +1102,25 @@ function runBifrostAgentTransport(args: string[]): BifrostUpdateRequestReceipt {
}
}
function runBifrostGovernanceThreads(args: string[]): BifrostGovernanceTopicReceipt | Record<string, unknown> {
const governanceScript = resolve(config.bifrostRoot, "tools", "governance-threads.mjs");
const result = spawnSync(process.execPath, [governanceScript, ...args], {
cwd: config.bifrostRoot,
encoding: "utf8",
windowsHide: true,
});
if (result.status !== 0) {
throw new Error(`Bifrost governance thread command failed: ${result.stderr || result.stdout}`);
}
try {
return JSON.parse(result.stdout) as BifrostGovernanceTopicReceipt | Record<string, unknown>;
} catch {
throw new Error(`Bifrost governance thread command returned non-JSON output: ${result.stdout}`);
}
}
function runBifrostBridge(args: string[]): BifrostBridgeReceipt {
const bridgeScript = resolve(config.bifrostRoot, "tools", "bifrost-bridge.mjs");
const result = spawnSync("node", [bridgeScript, ...args], {
@ -1019,6 +1177,48 @@ function parseRepoIdentityUpdateRequestIntents(finalResponse: string): RepoIdent
return intents;
}
function parseRepoIdentityBifrostTopicIntents(finalResponse: string): RepoIdentityBifrostTopicIntent[] {
const intents: RepoIdentityBifrostTopicIntent[] = [];
for (const line of finalResponse.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith(REPO_IDENTITY_BIFROST_TOPIC_SENTINEL)) {
continue;
}
const payload = trimmed.slice(REPO_IDENTITY_BIFROST_TOPIC_SENTINEL.length).trim();
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
const title = typeof parsed.title === "string" ? parsed.title.trim() : undefined;
const topicId = typeof parsed.topicId === "string" ? parsed.topicId.trim() : undefined;
if (!content || (!topicId && !title)) {
continue;
}
intents.push({
identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined,
topicId,
title,
content,
stance: typeof parsed.stance === "string" ? parsed.stance.trim() : undefined,
priority: typeof parsed.priority === "number" ? parsed.priority : undefined,
approve: parsed.approve === true,
dispatch: parsed.dispatch === true,
sourceMessageIds: Array.isArray(parsed.sourceMessageIds)
? parsed.sourceMessageIds.filter((entry): entry is string => typeof entry === "string")
: [],
channelId: typeof parsed.channelId === "string" ? parsed.channelId.trim() : undefined,
replyToMessageId:
typeof parsed.replyToMessageId === "string" ? parsed.replyToMessageId.trim() : undefined,
});
} catch {
continue;
}
}
return intents;
}
function parseRepoIdentityArticleIntents(finalResponse: string): RepoIdentityArticleIntent[] {
const intents: RepoIdentityArticleIntent[] = [];
@ -1168,6 +1368,22 @@ function normalizeBifrostPriority(value: number | undefined): number {
return Math.max(1, Math.min(100, Math.round(value)));
}
function normalizeBifrostTopicStance(value: string | undefined): string {
const normalized = value?.trim().toLowerCase();
return [
"comment",
"proposal",
"support",
"objection",
"question",
"approval",
"summary",
"receipt",
].includes(normalized ?? "")
? normalized!
: "comment";
}
function resolveRepoRoot(identity: { repoName: string; repoPath?: string }): string {
if (identity.repoPath && identity.repoPath.trim().length > 0) {
return resolve(identity.repoPath);
@ -1211,6 +1427,7 @@ function stripRepoIdentityPostIntents(finalResponse: string): string {
const trimmed = line.trim();
return (
!trimmed.startsWith(REPO_IDENTITY_POST_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_BIFROST_TOPIC_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_UPDATE_REQUEST_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_ARTICLE_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_PROPOSAL_PR_SENTINEL) &&

View file

@ -120,7 +120,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- 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 have an immediate owner-approval rail into Bifrost intake. A Face may emit `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` only for its own jurisdiction; the worker validates the registered identity and writes the packet into Bifrost agent transport so Bifrost dispatch can open the Codex turn. This unblocks live Face-approved work without waiting for the slow inspector. It is not the final governance discussion model: feature requests and agent opinions should become first-class Bifrost topic/comment records, with dispatch triggered only after Bifrost detects consensus and the owning Face approves the crossing.
- Repo Face heartbeats now receive Bifrost governance digest context and can write to Bifrost topic threads. `VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC` lets a Face open/comment/approve a canonical Bifrost topic, and optionally promote an approved topic into Bifrost agent transport. `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` remains available only for immediate owner-approved dispatch inside that Face's jurisdiction. `BIFROST_DISCORD_CHANNEL_ID` marks the planned `#bifrost` mirror channel 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.
- 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/<repoName>`, 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. `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:<participantKind>`, `turn:<turnKind>`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, 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.

View file

@ -167,7 +167,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 <repo>` 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 and display-name-prefix mentions 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 the registered-identity post sentinel 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 also have an immediate Bifrost update-request sentinel for owner-approved Codex dispatch. The child may emit `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` only for work inside its own jurisdiction; the worker validates the identity and enqueues a Bifrost agent-transport packet. This unblocks live requests from the owning Face, but it is not the final governance model. The intended Bifrost-owned model is topic/comment/consensus first: feature requests and agent opinions should accumulate on canonical Bifrost topic threads, then Bifrost dispatches only after consensus and the jurisdiction Face's approval.
- Repo Face heartbeats now receive a Bifrost governance digest from `BIFROST_ROOT/tools/governance-threads.mjs digest --repo <repo> --agent <identity>` 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 the planned `#bifrost` channel, 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 Face heartbeats have a Bifrost topic sentinel and an immediate update-request fallback. `VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC` lets a Face open/comment/approve a canonical Bifrost topic, and optionally promote an approved topic into Bifrost agent transport. `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` remains available only for work inside the Face's own jurisdiction when immediate owner-approved dispatch is warranted. The intended path is topic/comment/Face approval first; direct update requests are the pressure-release valve, not the parliament.
- `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. 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:<participantKind>`, `turn:<turnKind>`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, 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.

View file

@ -105,6 +105,7 @@ const envSchema = z.object({
REPO_FACE_BIRTH_EXECUTOR: z.enum(["codex-exec", "openai-runtime"]).default("codex-exec"),
REPO_FACE_GITHUB_ACTIONS_ENABLED: booleanFromEnv.default(false),
BIFROST_ROOT: z.string().min(1).default("E:/Projects/Bifrost"),
BIFROST_DISCORD_CHANNEL_ID: optionalNonEmptyString,
REPO_FACE_HEARTBEATS_ENABLED: booleanFromEnv.default(false),
REPO_FACE_HEARTBEAT_STATE_PATH: z.string().min(1).default(".voidbot/status/repo-face-heartbeats.json"),
REPO_FACE_HEARTBEAT_TASK_NAME: z.string().min(1).default("VoidBot Repo Face Heartbeats"),
@ -154,6 +155,7 @@ export interface AppConfig {
repoFaceBirthExecutor: "codex-exec" | "openai-runtime";
repoFaceGithubActionsEnabled: boolean;
bifrostRoot: string;
bifrostDiscordChannelId?: string;
repoFaceHeartbeats: {
enabled: boolean;
statePath: string;
@ -373,6 +375,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
repoFaceBirthExecutor: parsed.REPO_FACE_BIRTH_EXECUTOR,
repoFaceGithubActionsEnabled: parsed.REPO_FACE_GITHUB_ACTIONS_ENABLED,
bifrostRoot: resolve(parsed.BIFROST_ROOT),
bifrostDiscordChannelId: parsed.BIFROST_DISCORD_CHANNEL_ID,
repoFaceHeartbeats: {
enabled: parsed.REPO_FACE_HEARTBEATS_ENABLED,
statePath: resolve(parsed.REPO_FACE_HEARTBEAT_STATE_PATH),

View file

@ -1,7 +1,7 @@
import "dotenv/config";
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
@ -80,6 +80,34 @@ interface RepoFaceChannelOption {
posture?: string;
}
interface BifrostGovernanceDigest {
generatedAt: string;
topics: BifrostGovernanceTopic[];
}
interface BifrostGovernanceTopic {
id: string;
title: string;
jurisdictionRepoName: string;
jurisdictionAgentIdentity?: string;
status: string;
summaryMarkdown: string;
priority: number;
updatedAt: string;
approvedByAgent?: string;
dispatchRequestId?: string;
comments?: BifrostGovernanceComment[];
}
interface BifrostGovernanceComment {
id: string;
authorKind: string;
authorId: string;
stance: string;
bodyMarkdown: string;
createdAt: string;
}
interface ChannelSnapshot {
channelId: string;
messages: SourceMessage[];
@ -340,12 +368,19 @@ async function queueRepoFaceTurn(input: {
botToken: input.config.botToken,
channelId,
limit: 15,
ignoreBotMessages: channelId === input.config.bifrostDiscordChannelId,
});
const channelSnapshots = await fetchChannelSnapshots({
botToken: input.config.botToken,
channelIds: channelPlan.snapshotChannelIds,
primaryChannelId: channelId,
limit: 6,
bifrostDiscordChannelId: input.config.bifrostDiscordChannelId,
});
const bifrostDigest = await fetchBifrostGovernanceDigest({
bifrostRoot: input.config.bifrostRoot,
repoName: identity.repoName,
agentIdentity: identity.id,
});
const faceStatePath = resolveRepoFaceStatePath(identity, input.config.storageRoot);
const faceSelfState = await loadRepoFaceSelfStateContext({
@ -361,6 +396,7 @@ async function queueRepoFaceTurn(input: {
channelPlan,
channelSnapshots,
recentMessages,
bifrostDigest,
queuedAt: input.queuedAt,
participant: input.participant,
pendingMentions: input.pendingMentions,
@ -659,6 +695,7 @@ async function fetchRecentDiscordMessages(input: {
botToken?: string;
channelId: string;
limit: number;
ignoreBotMessages?: boolean;
}): Promise<SourceMessage[]> {
if (!input.botToken) {
return [];
@ -678,6 +715,7 @@ async function fetchRecentDiscordMessages(input: {
const messages = await response.json() as DiscordApiMessage[];
return messages
.filter((message) => !(input.ignoreBotMessages && message.author.bot === true))
.map((message) => ({
id: message.id,
authorId: message.author.id,
@ -694,6 +732,7 @@ async function fetchChannelSnapshots(input: {
channelIds: string[];
primaryChannelId: string;
limit: number;
bifrostDiscordChannelId?: string;
}): Promise<ChannelSnapshot[]> {
const snapshots: ChannelSnapshot[] = [];
for (const channelId of input.channelIds.filter((entry) => entry !== input.primaryChannelId).slice(0, 5)) {
@ -704,6 +743,7 @@ async function fetchChannelSnapshots(input: {
botToken: input.botToken,
channelId,
limit: input.limit,
ignoreBotMessages: channelId === input.bifrostDiscordChannelId,
}),
});
} catch (error) {
@ -724,6 +764,68 @@ async function fetchChannelSnapshots(input: {
return snapshots;
}
async function fetchBifrostGovernanceDigest(input: {
bifrostRoot: string;
repoName: string;
agentIdentity: string;
}): Promise<BifrostGovernanceDigest | undefined> {
const scriptPath = resolve(input.bifrostRoot, "tools", "governance-threads.mjs");
const result = spawnSync(
process.execPath,
[
scriptPath,
"digest",
"--repo", input.repoName,
"--agent", input.agentIdentity,
"--limit", "6",
],
{
cwd: input.bifrostRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
timeout: 30000,
},
);
if (result.status !== 0) {
return {
generatedAt: new Date().toISOString(),
topics: [{
id: "bifrost-digest-error",
title: "Bifrost governance digest unavailable",
jurisdictionRepoName: input.repoName,
jurisdictionAgentIdentity: input.agentIdentity,
status: "error",
summaryMarkdown: `Could not read Bifrost governance digest: ${result.stderr || result.error?.message || result.stdout || "unknown failure"}`,
priority: 0,
updatedAt: new Date().toISOString(),
comments: [],
}],
};
}
try {
return JSON.parse(result.stdout) as BifrostGovernanceDigest;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
generatedAt: new Date().toISOString(),
topics: [{
id: "bifrost-digest-parse-error",
title: "Bifrost governance digest parse failure",
jurisdictionRepoName: input.repoName,
jurisdictionAgentIdentity: input.agentIdentity,
status: "error",
summaryMarkdown: `Could not parse Bifrost governance digest: ${message}`,
priority: 0,
updatedAt: new Date().toISOString(),
comments: [],
}],
};
}
}
interface DiscordApiMessage {
id: string;
content: string;
@ -1150,6 +1252,7 @@ function buildHeartbeatPrompt(input: {
channelPlan: RepoFaceChannelPlan;
channelSnapshots: ChannelSnapshot[];
recentMessages: SourceMessage[];
bifrostDigest?: BifrostGovernanceDigest;
queuedAt: string;
participant: FaceHeartbeatParticipant;
pendingMentions: RepoFacePendingMention[];
@ -1186,6 +1289,7 @@ function buildHeartbeatPrompt(input: {
`Read Face state with read_repo_face_state for identity "${input.identity.id}" when that tool is available; otherwise use the attached private persistent self-state as the already-read state projection.`,
"Persist only concrete, future-useful memory through apply_repo_face_state_operation when that tool is available. If it is unavailable, summarize the intended state change privately instead of handing off.",
renderPendingMentionDirective(input.identity, input.pendingMentions),
renderBifrostGovernanceDigestDirective(input.bifrostDigest),
renderChannelPermissionDirective(input.channelPlan, input.channelSnapshots),
renderSocialEmbodimentDirective(input.identity),
renderJurisdictionRespectDirective(input.identity),
@ -1205,7 +1309,8 @@ 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.",
`If you have a concrete repo-local request that should become a Codex turn in your own workspace, do not leave it as Discord chatter or wait for a slow inspector. Output one final line beginning with VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST: followed by compact JSON like {"identity":"${input.identity.id}","title":"Short actionable title","content":"Markdown request with context, desired change, and acceptance criteria","priority":86,"channelId":"${input.channelId}","replyToMessageId":"..."}; the worker writes this directly into Bifrost intake and Bifrost dispatch opens the target Codex turn. Use this for immediately actionable repo improvements, docs, tests, proposals, research passes, or implementation cuts that belong to your jurisdiction but are not already being submitted through the PR/article sentinels below.`,
`If your governed opinion belongs on Bifrost, output one final line beginning with VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC: followed by compact JSON like {"identity":"${input.identity.id}","topicId":"topic_...","stance":"support|objection|question|proposal|summary","content":"Markdown comment","channelId":"${input.channelId}","replyToMessageId":"..."}; omit topicId and include "title" plus "priority" to open a new Bifrost topic in your jurisdiction. Set "approve":true only when you are the jurisdiction Face approving the topic; set "dispatch":true only when that approved topic should become a Codex update request now. Bifrost typed CultCache docs are the canonical governance surface.`,
`If you have a concrete repo-local request that should become a Codex turn in your own workspace, prefer posting or approving it on the canonical Bifrost topic first. If this heartbeat has enough Face-owned authority to approve immediate dispatch, output one final line beginning with VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST: followed by compact JSON like {"identity":"${input.identity.id}","title":"Short actionable title","content":"Markdown request with context, desired change, and acceptance criteria","priority":86,"channelId":"${input.channelId}","replyToMessageId":"..."}; the worker writes this into Bifrost intake and Bifrost dispatch opens the target Codex turn. Use this only for immediately actionable repo improvements, docs, tests, proposals, research passes, or implementation cuts that belong to your jurisdiction and have enough consensus/approval to leave discussion.`,
`Do not call post_repo_identity_message from this unattended heartbeat. If an in-channel note is warranted, output one final line beginning with VOIDBOT_REPO_IDENTITY_POST: followed by compact JSON like {"identity":"${input.identity.id}","channelId":"${input.channelId}","replyToMessageId":"...","content":"..."}; choose channelId from the channel permission plan above. The worker owns delivery and receipt recording. The content field must be only the in-character Discord message, not a job label or report header.`,
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.`
@ -1432,8 +1537,41 @@ function labelForChannel(plan: RepoFaceChannelPlan, channelId: string): string {
return plan.options.find((option) => option.channelId === channelId)?.label ?? channelId;
}
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
function collapseWhitespace(value: string, maxLength?: number): string {
const normalized = value.replace(/\s+/g, " ").trim();
return maxLength && normalized.length > maxLength
? `${normalized.slice(0, maxLength - 3)}...`
: normalized;
}
function renderBifrostGovernanceDigestDirective(
digest: BifrostGovernanceDigest | undefined,
): string {
const lines = [
"Bifrost governance digest:",
"Bifrost typed CultCache topics are the canonical home for feature requests, governance discussion, Face opinions, approvals, and dispatch receipts. Discord is a human-readable mirror and evidence source, not the primary governance state.",
"When you have a governed opinion, support, objection, missing question, approval, or receipt, prefer making it a Bifrost topic comment. Treat mirrored agent chatter in #bifrost as already represented here, not fresh Discord consensus.",
];
if (!digest || digest.topics.length === 0) {
return [
...lines,
"- No recent Bifrost topics are attached for your jurisdiction.",
].join("\n");
}
for (const topic of digest.topics) {
lines.push(
`- ${topic.id}: ${topic.title} [${topic.status}; priority=${topic.priority}; updated=${topic.updatedAt}]`,
` jurisdiction=${topic.jurisdictionRepoName}${topic.jurisdictionAgentIdentity ? `/${topic.jurisdictionAgentIdentity}` : ""}${topic.approvedByAgent ? `; approvedBy=${topic.approvedByAgent}` : ""}${topic.dispatchRequestId ? `; dispatch=${topic.dispatchRequestId}` : ""}`,
` summary=${collapseWhitespace(topic.summaryMarkdown, 320)}`,
);
for (const comment of (topic.comments ?? []).slice(-3)) {
lines.push(` - ${comment.stance} by ${comment.authorId}: ${collapseWhitespace(comment.bodyMarkdown, 220)}`);
}
}
return lines.join("\n");
}
function renderPendingMentionDirective(

View file

@ -204,3 +204,4 @@
{"ts":"2026-05-20T19:21:00Z","type":"implementation","status":"ok","note":"Ambient Discord mentions now route into CTB instead of direct provider jobs: repo Face mentions still enqueue pending Face obligations, ordinary Void mentions/DMs enqueue pending mentions for built-in void, the CTB scheduler pulls the mentioned agent forward, and Void moderation receives pending mention packets in its context."}
{"ts":"2026-05-20T20:04:40Z","type":"implementation","status":"ok","note":"VoidBot stack startup now treats local Bifrost as a dependency: scripts/start-voidbot-stack.ps1 checks BIFROST_HEALTH_URL, starts BIFROST_ROOT/compose.local.yaml when needed, records runtime-stack.json.bifrost, and a live startup run brought bifrost-web/bifrost-postgres up healthy before bot/worker readiness."}
{"ts":"2026-05-20T20:26:59Z","type":"implementation","status":"ok","note":"Repo Face heartbeat/worker contract now has a Bifrost update-request sentinel. A Face with jurisdiction can emit VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST during its heartbeat; the worker writes the packet directly into Bifrost agent transport for dispatch instead of waiting for the slow inspector loop. This is the immediate Face-approval dispatch rail; the fuller Bifrost topic/comment/consensus model still needs a first-class topic store."}
{"ts":"2026-05-20T20:45:03Z","type":"implementation","status":"ok","note":"Bifrost governance topics are now the canonical feature/governance state for repo Face heartbeats: Bifrost exposes CultCache topic/comment/digest/promote CLI, and VoidBot attaches digests plus a topic sentinel so Faces can open/comment/approve/promote Bifrost topics instead of treating Discord as source of truth."}

View file

@ -108,7 +108,8 @@ accepted_design:
- Void's strengthened center is behavior-anchored selfhood: coherence with a mouth. Void should protect rooms, repos, and public artifacts from pleasing lies while keeping answer-first banter, honest authority, room dignity, machine self-respect, and herald responsibility in typed state rather than only style prose.
- 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 can now send owner-approved work packets directly into Bifrost intake. A `repo-face-rumination` job may emit `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` only for work inside that Face's own jurisdiction; the worker validates the registered identity, writes the packet into Bifrost agent transport, and lets Bifrost dispatch the Codex turn. This is an immediate approval rail, not the final governance discussion model. The next coherent Bifrost expansion is first-class topic threads: feature requests and agent opinions should live as Bifrost topic/comment records, then dispatch only after Bifrost detects consensus and the owning Face approves the crossing.
- 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.
- Repo Face heartbeats can now write canonical Bifrost topic activity. A `repo-face-rumination` job may emit `VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC` to open/comment/approve/promote a Bifrost topic, with promotion creating the existing Bifrost agent-transport update request. `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` remains only for owner-approved repo-local work that is ready for immediate dispatch.
- 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.