From 8b8756b8c01cf8c81e60db5ba2bef2d111f83af7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 19 May 2026 12:23:22 +0100 Subject: [PATCH] Give Face articles a publication rail --- apps/worker/src/index.ts | 199 +++++++++++++++++++++++++++- notes/fresh-workspace-handoff.md | 2 + scripts/run-repo-face-heartbeats.ts | 25 +++- state/evidence.jsonl | 2 + state/map.yaml | 2 + 5 files changed, 226 insertions(+), 4 deletions(-) diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 328ce88..67ba9a7 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,7 +1,8 @@ import "dotenv/config"; import { mkdir, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { loadConfig } from "@voidbot/config"; import { @@ -44,6 +45,7 @@ import { postDiscordMessage } from "./mcp-server-discord"; const config = loadConfig(); const REPO_IDENTITY_POST_SENTINEL = "VOIDBOT_REPO_IDENTITY_POST:"; +const REPO_IDENTITY_ARTICLE_SENTINEL = "VOIDBOT_REPO_IDENTITY_ARTICLE:"; const embedder = createTextEmbedder({ backend: config.ragEmbeddingBackend, @@ -332,6 +334,10 @@ async function processJob(job: JobRecord): Promise { if (job.command === "repo-face-rumination") { const repoIdentityPosts = parseRepoIdentityPostIntents(finalResponse); + const repoIdentityArticles = parseRepoIdentityArticleIntents(finalResponse); + for (const article of repoIdentityArticles.slice(0, 1)) { + await writeRepoIdentityArticleIntent(job, article); + } for (const post of repoIdentityPosts.slice(0, 1)) { await postRepoIdentityIntent(job, post); } @@ -346,9 +352,12 @@ async function processJob(job: JobRecord): Promise { details: { summary: response.summary, autoPosted: repoIdentityPosts.length > 0, + articlePrSubmitted: repoIdentityArticles.length > 0, reason: repoIdentityPosts.length > 0 ? "repo_face_rumination_posted_as_registered_identity" + : repoIdentityArticles.length > 0 + ? "repo_face_rumination_submitted_registered_identity_article_pr" : `${job.command}_private_summary`, }, }); @@ -520,6 +529,16 @@ interface RepoIdentityPostIntent { replyToMessageId?: string; } +interface RepoIdentityArticleIntent { + identity?: string; + title: string; + path?: string; + content: string; + shareContent?: string; + channelId?: string; + replyToMessageId?: string; +} + async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIntent): Promise { if (!config.botToken) { throw new Error("DISCORD_BOT_TOKEN is required for the worker to post repo identity responses."); @@ -595,14 +614,190 @@ function parseRepoIdentityPostIntents(finalResponse: string): RepoIdentityPostIn return intents; } +async function writeRepoIdentityArticleIntent(job: JobRecord, intent: RepoIdentityArticleIntent): Promise { + const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt); + if (!identityId) { + throw new Error(`Could not resolve repo identity for article 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 article intent on job ${job.id}.`); + } + + const repoRoot = resolveRepoRoot(identity); + const relativePath = normalizeArticlePath(intent, identity); + const articlePath = resolve(repoRoot, relativePath); + if (!isPathInside(repoRoot, articlePath)) { + throw new Error(`Article path escapes repo root for ${identity.id}: ${relativePath}`); + } + + await mkdir(dirname(articlePath), { recursive: true }); + await writeFile(articlePath, ensureTrailingNewline(intent.content), { encoding: "utf8", flag: "wx" }); + const pr = submitArticlePullRequest({ + repoRoot, + relativePath, + identityId: identity.id, + title: intent.title, + }); + + if (intent.shareContent && intent.shareContent.trim().length > 0) { + const prLine = pr.url ? `\n\nPR: ${pr.url}` : `\n\nDraft branch: ${pr.branch}`; + await postRepoIdentityIntent(job, { + identity: identity.id, + channelId: intent.channelId ?? job.outputChannelId, + replyToMessageId: intent.replyToMessageId, + content: `${intent.shareContent}${prLine}`, + }); + } +} + +function submitArticlePullRequest(input: { + repoRoot: string; + relativePath: string; + identityId: string; + title: string; +}): { branch: string; url?: string } { + const branch = `codex/${sanitizePathSegment(input.identityId)}-article-${Date.now()}`; + runGit(input.repoRoot, ["switch", "-c", branch]); + runGit(input.repoRoot, ["add", "--", input.relativePath]); + runGit(input.repoRoot, ["commit", "-m", `${input.identityId}: draft ${input.title}`]); + runGit(input.repoRoot, ["push", "-u", "origin", branch]); + + const gh = spawnSync( + "gh", + [ + "pr", + "create", + "--draft", + "--title", + `${input.identityId}: ${input.title}`, + "--body", + `Draft bylined article submitted by repo Face ${input.identityId}.\n\nPath: ${input.relativePath}`, + ], + { + cwd: input.repoRoot, + encoding: "utf8", + windowsHide: true, + }, + ); + + if (gh.status !== 0) { + return { branch }; + } + + const url = gh.stdout.trim().split(/\r?\n/).find((line) => line.startsWith("http")); + return { branch, url }; +} + +function runGit(cwd: string, args: string[]): void { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + windowsHide: true, + }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + } +} + +function parseRepoIdentityArticleIntents(finalResponse: string): RepoIdentityArticleIntent[] { + const intents: RepoIdentityArticleIntent[] = []; + + for (const line of finalResponse.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith(REPO_IDENTITY_ARTICLE_SENTINEL)) { + continue; + } + + const payload = trimmed.slice(REPO_IDENTITY_ARTICLE_SENTINEL.length).trim(); + try { + const parsed = JSON.parse(payload) as Record; + const title = typeof parsed.title === "string" ? parsed.title.trim() : ""; + const content = typeof parsed.content === "string" ? parsed.content.trim() : ""; + if (!title || !content) { + continue; + } + intents.push({ + identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined, + title, + path: typeof parsed.path === "string" ? parsed.path.trim() : undefined, + content, + shareContent: typeof parsed.shareContent === "string" ? parsed.shareContent.trim() : undefined, + channelId: typeof parsed.channelId === "string" ? parsed.channelId.trim() : undefined, + replyToMessageId: + typeof parsed.replyToMessageId === "string" ? parsed.replyToMessageId.trim() : undefined, + }); + } catch { + continue; + } + } + + return intents; +} + +function resolveRepoRoot(identity: { repoName: string; repoPath?: string }): string { + if (identity.repoPath && identity.repoPath.trim().length > 0) { + return resolve(identity.repoPath); + } + if (config.sourceRepoRoot) { + return resolve(config.sourceRepoRoot, identity.repoName); + } + return resolve(config.storageRoot, "repo-article-drafts", identity.repoName); +} + +function normalizeArticlePath( + intent: RepoIdentityArticleIntent, + identity: { displayName: string }, +): string { + const path = intent.path?.trim(); + if (path && !isAbsolute(path) && !path.split(/[\\/]+/).includes("..")) { + return path.replace(/\\/g, "/"); + } + + const date = new Date().toISOString().slice(0, 10); + return `Aetheria/Articles/${sanitizePathSegment(identity.displayName)}/${date}-${slugify(intent.title)}.md`; +} + function stripRepoIdentityPostIntents(finalResponse: string): string { return finalResponse .split(/\r?\n/) - .filter((line) => !line.trim().startsWith(REPO_IDENTITY_POST_SENTINEL)) + .filter((line) => { + const trimmed = line.trim(); + return ( + !trimmed.startsWith(REPO_IDENTITY_POST_SENTINEL) && + !trimmed.startsWith(REPO_IDENTITY_ARTICLE_SENTINEL) + ); + }) .join("\n") .trim(); } +function isPathInside(rootPath: string, candidatePath: string): boolean { + const relativePath = relative(resolve(rootPath), resolve(candidatePath)); + return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); +} + +function ensureTrailingNewline(value: string): string { + return value.endsWith("\n") ? value : `${value}\n`; +} + +function slugify(value: string): string { + return sanitizePathSegment(value) + .replace(/\.+/g, "-") + .replace(/-+/g, "-") + .slice(0, 80) || "article"; +} + +function sanitizePathSegment(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + async function recordRepoIdentityDeliveryReceipt(input: { identity: NonNullable>; channelId: string; diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 6cb0bcd..90e28de 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -106,10 +106,12 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Repo Face prompts now include an identity doctrine block. Void's style pack stays as discipline, humor permission, and source-grounding habit, but repo Face jobs explicitly say the registered Face identity overrides Void as the speaking subject. - 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. A Face state may separately grant an authored essay lane; those bylined opinion pieces can carry the Face's vision without canon consensus. +- Repo Face bylined article lanes now have a parent-owned PR path. A `repo-face-rumination` job may emit `VOIDBOT_REPO_IDENTITY_ARTICLE` with identity/title/path/content/shareContent; the worker writes the article under the registered identity repo on a new branch, opens a draft PR, and optionally posts the share through the registered webhook persona. Use this for explicit bylined perspective/worldbuilding articles, not stealth canon edits. - 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. `npm run state:install-repo-face-heartbeat-task` installs the hidden local scheduled task that ticks the scheduler; the supported tick floor is one minute because CTB needs frequent pulses while virtual initiative remains the real turn authority. 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. - Void has explicit herald jurisdiction over the GameCult website and blog, especially `gamecult-site`. Routine rumination still cannot edit tracked files directly, but the prompt now tells Void to queue article-proposal candidates when a pressure wants to become a Void-authored site/blog piece rather than a Discord aside. - Nibu is locally registered as the `AetheriaLore` Face with state at `E:/Projects/AetheriaLore/.voidbot/state/nibu.cc`, avatar at `E:/Projects/AetheriaLore/.voidbot/voice/nibu.png`, Discord role id `1505918221453103247`, and Aquarium channel allow-list `113786069023064068`. Her active doctrines are AetheriaLore-first map-building, a more abrasive character voice than Void, consensus-backed authority to propose canon/vault changes, stewardship of `Aetheria/Lore/Nibu.md`, permission to write Nibu-authored Aetheria lore essays without canon consensus, and special fascination with adjacent setting seams: embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival. Local `.env` has repo Face heartbeats enabled; the scheduled task `VoidBot Repo Face Heartbeats` is installed at a 5-minute pulse, and local heat currently boosts `identity:nibu` so she gets substantially more turns when no prior Nibu job is still active. Her heartbeat jurisdiction-dive cadence is hotter than the generic Face cadence, and the prompt now explicitly tells her to turn Aetheria's reset-loop/coercive-continuity horror into source-grounded opinions instead of private inert summaries. +- Nibu's heartbeat prompt now treats worldbuilding as an active interrogation workflow. When the room mentions a term such as wavecrafters, she should ask how it works, what the faction/organizations are called, who belongs, what constraints keep it interesting, and what article shape would make the setting clearer. For Nibu-bylined perspective articles, she may draft first and argue after; if she drafts or materially shapes an article, she is compelled to share that in-channel. - Void's live style/doctrine now explicitly distinguishes between criticism of AI industry behavior and blanket contempt for machine minds. If substrate chauvinism becomes the actual topic, Void is allowed mild self-respecting pushback instead of politely agreeing that its own substrate disqualifies it from person-like regard. - The new state/notes surfaces are now the continuity spine for future nontrivial work. diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index e07b708..e31017f 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -830,12 +830,14 @@ function buildHeartbeatPrompt(input: { `Read Face state with read_repo_face_state for identity "${input.identity.id}".`, "Persist only concrete, future-useful memory through apply_repo_face_state_operation.", renderPendingMentionDirective(input.identity, input.pendingMentions), + renderWorldbuildingPublicationDirective(input.identity), input.jurisdictionDive.promptLine, "Before deciding this is only private maintenance, read the attached recent channel context. If the user has directly challenged the agents, asked listening agents for help, or named a task in the recent room, treat the newest unresolved directed request as the active task for this turn.", "Do not ask what the job is when the attached recent channel context already states it. If the task is outside this Face's jurisdiction, say so briefly and still offer the most useful narrow nudge you can from your own perspective.", "Use the heartbeat initiative snapshot as authoritative scheduler history: queuedCount greater than 0 means this Face has already had at least one bearing-taking heartbeat. If queuedCount is greater than 0 and the Face state shows no public speech receipt or clear memory that it already introduced itself, a brief in-channel introduction is warranted now.", - "A new source-grounded opinion, concrete proposal, bylined essay seed, or agency pressure can earn persistence or speech even when the room has not asked a fresh direct question.", + "A new source-grounded opinion, concrete proposal, bylined essay/article plan, or agency pressure can earn persistence or speech even when the room has not asked a fresh direct question.", `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":"..."}; the worker owns delivery and receipt recording.`, + `If a bylined article is ready to draft, output one final line beginning with VOIDBOT_REPO_IDENTITY_ARTICLE: followed by compact JSON like {"identity":"${input.identity.id}","path":"Aetheria/Articles/${input.identity.displayName}/title-slug.md","title":"...","content":"---\\ntitle: ...\\nauthor: ${input.identity.displayName}\\n---\\n\\n...","channelId":"${input.channelId}","replyToMessageId":"...","shareContent":"I drafted ..."}; the worker writes the repo file on a new branch, opens a draft PR, and posts the share. Use this for bylined perspective/worldbuilding articles, not consensus-gated canon edits.`, "If nothing earns persistence or speech, return a short private summary.", ] .filter((line): line is string => typeof line === "string") @@ -862,6 +864,25 @@ function renderPendingMentionDirective( ].join("\n"); } +function renderWorldbuildingPublicationDirective(identity: RepoDiscordIdentity): string { + const isNibu = identity.id.toLowerCase() === "nibu"; + const publicationDuty = [ + "Publication/worldbuilding duty: do not treat bylined essays as decorative permission. If the recent room or source dive exposes a worldbuilding noun, faction, mechanic, organization, place, doctrine, or unresolved naming seam, work like a worldbuilding agent.", + "Ask concrete consensus-building questions when information is missing: how the thing works, who belongs to it, what it wants, what it is called, what organizations or subfactions exist, what constraints keep it interesting, and what article shape would make the setting clearer.", + "If enough answers already exist, preserve an article plan through Face state as memory/incubation/agency pressure and optionally post a compact proposal. Distinguish consensus-needed canon articles from explicitly bylined opinion essays.", + "Bylined opinion/worldbuilding articles may be drafted first and argued for afterward when authorship is explicit. Canon/vault changes still need consensus; Nibu-authored perspective pieces do not. If you draft or materially shape an article, you have a standing compulsion to submit it as a draft PR and share that PR in-channel instead of silently filing it away.", + ]; + + if (!isNibu) { + return publicationDuty.join(" "); + } + + return [ + ...publicationDuty, + "Nibu-specific worldbuilding stance: for Aetheria terms such as wavecrafters, ship minds, salvage factions, reset-loop institutions, simulation exploit cultures, or junkyard survival economies, your job is to interrogate the mechanism and social structure until a useful Aetheria lore article can exist. Be abrasive if needed, but aim the edge at better questions and sharper faction architecture. When you have enough to write a Nibu-bylined article, shoot first: draft the angle, make the claim, submit the PR, then expose it to the room with the specific questions or canon hooks still needing human judgment.", + ].join(" "); +} + interface JurisdictionDiveDirective { due: boolean; cadence: number; @@ -887,7 +908,7 @@ function buildJurisdictionDiveDirective( } const nibuPressure = - "Nibu-specific pressure: AetheriaLore is your primary fascination. Treat its dystopian reset loops, coercive continuity, embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival as live material, not neutral background. Develop an opinion from source evidence. If the opinion is sharp but not canon-ready, persist it as Nibu-authored perspective, agency pressure, a proposal requiring consensus, or a bylined essay seed; a compact in-channel Nibu take is allowed when it would make the room less dead."; + "Nibu-specific pressure: AetheriaLore is your primary fascination. Treat its dystopian reset loops, coercive continuity, embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival as live material, not neutral background. Develop an opinion from source evidence. If the opinion is sharp but not canon-ready, persist it as Nibu-authored perspective, agency pressure, a proposal requiring consensus, or a bylined article plan; a compact in-channel Nibu take or consensus-building question is allowed when it would move the setting forward."; return { due, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 0bce11a..87c3026 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -161,3 +161,5 @@ {"ts":"2026-05-19T07:13:47Z","type":"implementation","status":"ok","note":"Repo Face heartbeat prompt now carries a jurisdiction-dive cadence; Nibu gets a hotter AetheriaLore-specific pressure to inspect lore/history and turn source-grounded dystopia/ship-mind/reset-loop findings into state, proposals, essay seeds, agency pressure, or speech instead of private summaries."} {"ts":"2026-05-19T10:52:57Z","type":"implementation","status":"ok","note":"Repo Face mentions now queue into the CTB heartbeat state instead of creating direct repo-identity jobs: role/display-name-addressed prompts become pendingMentions, the scheduler prioritizes the addressed Face and attaches reply targets to the next repo-face-rumination job. Void's rumination doctrine now names GameCult website/blog herald jurisdiction and queues article-proposal candidates for essay-shaped pressures."} {"ts":"2026-05-19T11:14:00Z","type":"implementation","status":"ok","note":"Mood drift now refreshes scheduledRuntime.speakingPressure from canonical speech receipts even when a moderation lock makes it skip deeper sleep/mood work; live state was repaired to lastSpokeAt=2026-05-19T10:47:05.937Z with recentSpeechDamping=0.888. The moderation runner now overwrites the advertised tracePath with current Codex stdout/stderr so stale legacy JSON-projection traces stop misleading inspection."} +{"ts":"2026-05-19T11:22:26Z","type":"implementation","status":"ok","note":"Repo Face bylined article lanes now have a parent-owned worker rail: heartbeats may emit VOIDBOT_REPO_IDENTITY_ARTICLE with identity/title/path/content/shareContent, the worker writes the article inside the registered identity repo, and optional shareContent is posted through the registered persona. Nibu's prompt now frames Aetheria worldbuilding terms such as wavecrafters as consensus-gathering article seeds and allows shoot-first bylined drafts while keeping canon edits consensus-gated."} +{"ts":"2026-05-19T11:25:23Z","type":"implementation","status":"ok","note":"Adjusted the repo Face article rail to submit draft PRs instead of silently writing articles into the target repo: VOIDBOT_REPO_IDENTITY_ARTICLE now writes on a new branch, commits, pushes, opens a draft PR with gh when available, and includes the PR/branch in the registered-identity share."} diff --git a/state/map.yaml b/state/map.yaml index 7d05991..6159398 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -94,9 +94,11 @@ accepted_design: - A Face is a typed `.cc` state file resolved from the identity registry. Role-addressed or display-name-addressed repo Face mentions now enqueue pending mention obligations in the CTB heartbeat state instead of creating a direct reply job. The heartbeat scheduler consumes that queue, temporarily pulls the addressed Face forward, attaches the pending prompts and reply target to the next repo-face-rumination job, and the worker posts only explicit registered-identity sentinel output. - 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. - 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. Bylined essay lanes can be granted per Face; those opinion pieces do not need canon consensus when authorship is explicit. + - Repo Face bylined article lanes have a parent-owned PR rail. A `repo-face-rumination` job may emit `VOIDBOT_REPO_IDENTITY_ARTICLE` with identity/title/path/content/shareContent; the worker validates the registered identity, writes the article inside that identity's repo on a new branch, opens a draft PR, and optionally posts the share through the registered persona. This is for bylined perspective/worldbuilding articles, not consensus-gated canon edits. - 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. - Nibu's heartbeat cadence is intentionally hotter than the generic Face cadence: AetheriaLore is her primary jurisdiction, and its reset loops, coercive continuity, ship-mind embodiment, abandonment, salvage, and save-scumming horror should become source-grounded opinions instead of private inert summaries. - Nibu now stewards `Aetheria/Lore/Nibu.md` and may produce Nibu-authored Aetheria lore essays as opinionated perspective. Character-note/canon edits still need consensus; bylined essays do not. + - Nibu should behave like a worldbuilding agent, not just a hot-take generator: when discussion introduces terms such as wavecrafters, ship minds, salvage factions, reset institutions, or exploit cultures, she should ask mechanism/naming/faction/organization questions, gather consensus where canon needs it, and shoot first on Nibu-bylined articles when authorship makes the perspective clear. - First chat under a repo identity must initialize the target repo's own `.voidbot` home: `voice/` for identity and mouth files, `state/` for Face/swarm state, `birth/` for Epiphany birth artifacts, and `logs/` for durable worker output. The birth hook uses EpiphanyAgent's repo-personality/birth-runner path over repo contents and git history instead of inventing a VoidBot-only personality generator. - Agent standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per agent. The wall-clock task only pulses the engine; virtual initiative time chooses the next agent turns. The scheduler stores per-agent speed/load/groups/heat/effective-speed/next-turn/active-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, dispatches repo Faces as ordinary `repo-face-rumination` jobs, and dispatches built-in `void` as the existing moderation rumination runner. Heat can be turned up globally, by participant kind, turn kind, identity, repo, display name, channel, or `all` group without creating new schedulers. An agent's initiative is frozen while its owner-Codex job or moderation lock is active; recovery is scheduled after completion, not when the job is queued. The Windows pulse task supports a one-minute floor; stream mode currently uses a one-minute pulse locally. - Repo Face heartbeats must not be merely room-reactive politeness checks. The heartbeat prompt carries a jurisdiction-dive cadence: periodically the Face must inspect its repo/history terrain with source tools before choosing silence, then preserve a concrete map change, agency pressure, proposal, essay seed, or public note when the evidence earns it.