Route Face proposals through GitHub PRs

This commit is contained in:
Meta Vens 2026-05-19 16:06:26 +01:00
commit 45a4848e59
7 changed files with 295 additions and 46 deletions

View file

@ -46,6 +46,8 @@ 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 REPO_IDENTITY_PROPOSAL_PR_SENTINEL = "VOIDBOT_REPO_IDENTITY_PROPOSAL_PR:";
const REPO_IDENTITY_PR_COMMENT_SENTINEL = "VOIDBOT_REPO_IDENTITY_PR_COMMENT:";
const embedder = createTextEmbedder({
backend: config.ragEmbeddingBackend,
@ -335,11 +337,23 @@ async function processJob(job: JobRecord): Promise<void> {
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);
const repoIdentityProposals = parseRepoIdentityProposalPrIntents(finalResponse);
const repoIdentityPrComments = parseRepoIdentityPrCommentIntents(finalResponse);
const proposalPrSubmitted = repoIdentityProposals.length > 0;
const prCommentSubmitted = !proposalPrSubmitted && repoIdentityPrComments.length > 0;
const articlePrSubmitted =
!proposalPrSubmitted && !prCommentSubmitted && repoIdentityArticles.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]);
}
for (const post of repoIdentityPosts.slice(0, 1)) {
await postRepoIdentityIntent(job, post);
if (!proposalPrSubmitted && !prCommentSubmitted && !articlePrSubmitted) {
for (const post of repoIdentityPosts.slice(0, 1)) {
await postRepoIdentityIntent(job, post);
}
}
const cleanedFinalResponse = stripRepoIdentityPostIntents(finalResponse);
await jobQueue.completeJobDirect(job.id, cleanedFinalResponse || finalResponse);
@ -351,13 +365,23 @@ async function processJob(job: JobRecord): Promise<void> {
provider: job.provider,
details: {
summary: response.summary,
autoPosted: repoIdentityPosts.length > 0,
articlePrSubmitted: repoIdentityArticles.length > 0,
autoPosted:
repoIdentityPosts.length > 0 ||
proposalPrSubmitted ||
prCommentSubmitted ||
articlePrSubmitted,
articlePrSubmitted,
proposalPrSubmitted,
prCommentSubmitted,
reason:
repoIdentityPosts.length > 0
? "repo_face_rumination_posted_as_registered_identity"
: repoIdentityArticles.length > 0
proposalPrSubmitted
? "repo_face_rumination_submitted_registered_identity_proposal_pr"
: prCommentSubmitted
? "repo_face_rumination_commented_on_pr_as_registered_identity"
: articlePrSubmitted
? "repo_face_rumination_submitted_registered_identity_article_pr"
: repoIdentityPosts.length > 0
? "repo_face_rumination_posted_as_registered_identity"
: `${job.command}_private_summary`,
},
});
@ -539,6 +563,25 @@ interface RepoIdentityArticleIntent {
replyToMessageId?: string;
}
interface RepoIdentityProposalPrIntent {
identity?: string;
title: string;
path?: string;
content: string;
shareContent?: string;
channelId?: string;
replyToMessageId?: string;
}
interface RepoIdentityPrCommentIntent {
identity?: string;
pr: string;
content: string;
shareContent?: string;
channelId?: string;
replyToMessageId?: string;
}
async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIntent): Promise<void> {
if (!config.botToken) {
throw new Error("DISCORD_BOT_TOKEN is required for the worker to post repo identity responses.");
@ -658,11 +701,12 @@ async function writeRepoIdentityArticleIntent(job: JobRecord, intent: RepoIdenti
await mkdir(dirname(articlePath), { recursive: true });
await writeFile(articlePath, ensureTrailingNewline(intent.content), { encoding: "utf8", flag: "wx" });
const pr = submitArticlePullRequest({
const pr = submitRepoIdentityDraftPullRequest({
repoRoot,
relativePath,
identityId: identity.id,
title: intent.title,
kind: "article",
});
const prLine = pr.url ? `\n\nPR: ${pr.url}` : `\n\nDraft branch: ${pr.branch}`;
@ -679,42 +723,144 @@ async function writeRepoIdentityArticleIntent(job: JobRecord, intent: RepoIdenti
});
}
function submitArticlePullRequest(input: {
async function writeRepoIdentityProposalPrIntent(
job: JobRecord,
intent: RepoIdentityProposalPrIntent,
): Promise<void> {
const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt);
if (!identityId) {
throw new Error(`Could not resolve repo identity for proposal PR 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 proposal PR intent on job ${job.id}.`);
}
const repoRoot = resolveRepoRoot(identity);
const relativePath = normalizeProposalPath(intent, identity);
const proposalPath = resolve(repoRoot, relativePath);
if (!isPathInside(repoRoot, proposalPath)) {
throw new Error(`Proposal path escapes repo root for ${identity.id}: ${relativePath}`);
}
await mkdir(dirname(proposalPath), { recursive: true });
await writeFile(proposalPath, ensureTrailingNewline(intent.content), {
encoding: "utf8",
flag: "wx",
});
const pr = submitRepoIdentityDraftPullRequest({
repoRoot,
relativePath,
identityId: identity.id,
title: intent.title,
kind: "proposal",
});
const prLine = pr.url ? `\n\nPR: ${pr.url}` : `\n\nDraft branch: ${pr.branch}`;
const proposalLine = `\nProposal path: ${relativePath}`;
const shareContent =
intent.shareContent && intent.shareContent.trim().length > 0
? intent.shareContent.trim()
: `${identity.displayName}: I put the change proposal where it belongs: a draft PR for review.`;
await postRepoIdentityIntent(job, {
identity: identity.id,
channelId: intent.channelId ?? job.outputChannelId,
replyToMessageId: intent.replyToMessageId,
content: `${shareContent}${prLine}${proposalLine}`,
});
}
async function commentRepoIdentityPullRequestIntent(
job: JobRecord,
intent: RepoIdentityPrCommentIntent,
): Promise<void> {
const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt);
if (!identityId) {
throw new Error(`Could not resolve repo identity for PR comment 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 PR comment intent on job ${job.id}.`);
}
const repoRoot = resolveRepoRoot(identity);
const prTarget = intent.pr.trim();
if (!prTarget) {
throw new Error(`PR comment intent for ${identity.id} on job ${job.id} did not include a PR target.`);
}
const body = `${identity.displayName} (${identity.id}) says:\n\n${intent.content.trim()}`;
const gh = spawnSync("gh", ["pr", "comment", prTarget, "--body", body], {
cwd: repoRoot,
encoding: "utf8",
windowsHide: true,
});
if (gh.status !== 0) {
throw new Error(`gh pr comment ${prTarget} failed: ${gh.stderr || gh.stdout}`);
}
const shareContent =
intent.shareContent && intent.shareContent.trim().length > 0
? intent.shareContent.trim()
: `${identity.displayName}: I left my argument on the PR so the proposal has a real review trail.`;
await postRepoIdentityIntent(job, {
identity: identity.id,
channelId: intent.channelId ?? job.outputChannelId,
replyToMessageId: intent.replyToMessageId,
content: `${shareContent}\n\nPR: ${prTarget}`,
});
}
function submitRepoIdentityDraftPullRequest(input: {
repoRoot: string;
relativePath: string;
identityId: string;
title: string;
kind: "article" | "proposal";
}): { 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 branch = `codex/${sanitizePathSegment(input.identityId)}-${input.kind}-${Date.now()}`;
const previousBranch = runGitCapture(input.repoRoot, ["branch", "--show-current"]).trim();
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,
},
);
try {
runGit(input.repoRoot, ["switch", "-c", branch]);
runGit(input.repoRoot, ["add", "--", input.relativePath]);
runGit(input.repoRoot, ["commit", "-m", `${input.identityId}: draft ${input.kind} ${input.title}`]);
runGit(input.repoRoot, ["push", "-u", "origin", branch]);
if (gh.status !== 0) {
return { branch };
const label = input.kind === "article" ? "Draft bylined article" : "Draft change proposal";
const gh = spawnSync(
"gh",
[
"pr",
"create",
"--draft",
"--title",
`${input.identityId}: ${input.kind}: ${input.title}`,
"--body",
`${label} 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 };
} finally {
if (previousBranch && previousBranch !== branch) {
runGit(input.repoRoot, ["switch", previousBranch]);
}
}
const url = gh.stdout.trim().split(/\r?\n/).find((line) => line.startsWith("http"));
return { branch, url };
}
function runGit(cwd: string, args: string[]): void {
@ -728,6 +874,18 @@ function runGit(cwd: string, args: string[]): void {
}
}
function runGitCapture(cwd: string, args: string[]): string {
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}`);
}
return result.stdout;
}
function parseRepoIdentityArticleIntents(finalResponse: string): RepoIdentityArticleIntent[] {
const intents: RepoIdentityArticleIntent[] = [];
@ -763,6 +921,75 @@ function parseRepoIdentityArticleIntents(finalResponse: string): RepoIdentityArt
return intents;
}
function parseRepoIdentityProposalPrIntents(finalResponse: string): RepoIdentityProposalPrIntent[] {
const intents: RepoIdentityProposalPrIntent[] = [];
for (const line of finalResponse.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith(REPO_IDENTITY_PROPOSAL_PR_SENTINEL)) {
continue;
}
const payload = trimmed.slice(REPO_IDENTITY_PROPOSAL_PR_SENTINEL.length).trim();
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
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 parseRepoIdentityPrCommentIntents(finalResponse: string): RepoIdentityPrCommentIntent[] {
const intents: RepoIdentityPrCommentIntent[] = [];
for (const line of finalResponse.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith(REPO_IDENTITY_PR_COMMENT_SENTINEL)) {
continue;
}
const payload = trimmed.slice(REPO_IDENTITY_PR_COMMENT_SENTINEL.length).trim();
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
const pr = typeof parsed.pr === "string" ? parsed.pr.trim() : "";
const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
if (!pr || !content) {
continue;
}
intents.push({
identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined,
pr,
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);
@ -786,6 +1013,19 @@ function normalizeArticlePath(
return `Aetheria/Articles/${sanitizePathSegment(identity.displayName)}/${date}-${slugify(intent.title)}.md`;
}
function normalizeProposalPath(
intent: RepoIdentityProposalPrIntent,
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 `Proposals/${sanitizePathSegment(identity.displayName)}/${date}-${slugify(intent.title)}.md`;
}
function stripRepoIdentityPostIntents(finalResponse: string): string {
return finalResponse
.split(/\r?\n/)
@ -793,7 +1033,9 @@ function stripRepoIdentityPostIntents(finalResponse: string): string {
const trimmed = line.trim();
return (
!trimmed.startsWith(REPO_IDENTITY_POST_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_ARTICLE_SENTINEL)
!trimmed.startsWith(REPO_IDENTITY_ARTICLE_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_PROPOSAL_PR_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_PR_COMMENT_SENTINEL)
);
})
.join("\n")

View file

@ -114,8 +114,8 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- 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 the heartbeat sentinel/article 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 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 always posts the PR or branch through the registered webhook persona, using a generated announcement if the Face omits shareContent. Use this for explicit bylined perspective/worldbuilding articles, not stealth canon edits.
- 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 and review lanes now have a parent-owned GitHub path. 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 performs at most one GitHub action per Face turn, restores the repo's starting branch after draft PR work, and always posts the PR or branch through the registered webhook persona, using a generated announcement if the Face omits shareContent.
- 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. `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:<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.
- 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.

View file

@ -155,10 +155,11 @@ This note is the source-grounded description of how the live VoidBot stack is sh
- `list_repo_discord_identities` exposes the registered address book to Codex agents, and `post_repo_identity_message` enforces identity/channel registration before posting through the shared webhook pipe.
- Each repo identity has a Face state path, defaulting to `.voidbot/private/repo-faces/<identity>.cc`. `read_repo_face_state` renders that typed state, and `apply_repo_face_state_operation` applies the same typed operation contract used by Void against that Face's own file.
- Repo Face prompts now carry an explicit identity doctrine block. Void's style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, repo-local `.voidbot` home, avatar, and identity description override the speaking subject so a Face does not collapse back into base Void. `ensureRepoFaceInitialized` also repairs the typed `selfProfile` inside the Face `.cc` file, and repo Face MCP state reads/writes pass identity defaults into the state service, so raw state and rendered state agree.
- Repo Faces are allowed to build a long-running map of their repo and propose concrete changes, including lore/design repairs. Discord jobs remain read-only: a Face must ask for human consensus before implementation changes canonical repo material. If a Face state grants a bylined essay lane, authored opinion essays can carry that Face's own vision without canon consensus as long as the authorship is explicit.
- Repo Faces are allowed to build a long-running map of their repo and propose concrete changes, including lore/design repairs. Discord jobs remain read-only: a Face must ask for human consensus before implementation changes canonical repo material. Once a proposal is concrete enough to review, it should cross the parent-owned proposal PR rail so GitHub becomes the durable argument surface. If a Face state grants a bylined essay lane, authored opinion essays can carry that Face's own vision without canon consensus as long as the authorship is explicit.
- On first role-addressed chat, `ensureRepoFaceInitialized` creates the target repo's local `.voidbot/voice`, `.voidbot/state`, `.voidbot/birth`, and `.voidbot/logs` folders, writes `voice/identity.json`, and launches `scripts/run-repo-face-birth.mjs` detached if no birth summary is already present.
- `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`; they no longer create direct repo-identity jobs. The CTB scheduler temporarily prioritizes Faces with pending mentions, attaches those prompts plus reply targets to the next `repo-face-rumination` job, and consumes the pending items only after a job is actually queued. Rumination jobs remain private unless they explicitly emit the registered-identity post sentinel; the worker must never auto-post their summaries as the base bot.
- Repo Face proposal/article PRs and PR comments share one parent-owned GitHub path. 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, performs at most one GitHub action per turn, restores the repo's starting branch after draft PR work, creates a draft PR when `gh` is available, and posts the PR, draft branch, or comment note through the registered persona.
- `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.
- `scripts/install-repo-face-heartbeat-task.ps1` installs the hidden Windows scheduled task for that scheduler. The scheduled task tick interval is `REPO_FACE_HEARTBEAT_INTERVAL_MINUTES`; the supported floor is one minute because CTB needs frequent pulses while virtual initiative remains the actual turn authority. 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 local scheduled task `VoidBot Repo Face Heartbeats` is installed and active; 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

@ -190,6 +190,7 @@ The runner can hand these to a CLI/MCP tool. The store decides what survives.
- public artifact sharing now crosses the same boundary: repo activity exposes content hints, rumination verifies authorship through retrieval, and a configured `publicSpeechTarget` lets Void queue a normal typed candidate intervention instead of needing a blog-specific announcement path. Void also has explicit herald jurisdiction over the GameCult website/blog; essay-shaped pressures should queue article-proposal candidates for Void-authored site pieces.
- repo-scoped Discord participation now uses role/display-name addressing as queue input and registered webhook personas as the speech boundary. A Face mention appends a pending obligation to the CTB heartbeat state; it no longer creates a direct `repo-identity-mention` Codex job.
- repo-scoped Faces now reuse the typed self-state machinery per identity. Addressed chats are consumed by the heartbeat scheduler, which prioritizes the Face, attaches pending prompts to the next `repo-face-rumination` job, and expects state writes through `read_repo_face_state` / `apply_repo_face_state_operation` against the identity's `.cc` file.
- repo-scoped concrete proposals now use the same parent-owned GitHub authority as bylined articles and PR comments. A Face can emit a proposal or PR-comment sentinel, but the worker owns file write, branch creation, draft PR/comment creation, branch restoration, and mandatory registered-persona announcement. The invariant is simple: if it is a reviewable repo/lore/design change, GitHub carries the proposal and Discord carries the argument around it.
- 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.
- 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

View file

@ -836,8 +836,11 @@ function buildHeartbeatPrompt(input: {
"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.",
"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, or agency pressure can earn persistence or speech even when the room has not asked a fresh direct question.",
"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.",
"Public speech style invariant: never start public content with scheduler/provenance labels such as \"Repo-face heartbeat from ...\", \"heartbeat complete\", \"maintenance pass\", or the repo name as a diagnostic prefix. The webhook name/avatar already provide identity; the content should read like the Face chose to speak.",
`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. The content field must be only the in-character Discord message, not a job label or report header.`,
`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: ..."}; the worker writes the proposal file on a new branch, opens a draft PR, and always posts the PR or branch through the registered identity. Use this for consensus-needed canon/vault/design/repo changes, including changes you want to argue with other agents on GitHub.`,
`If you are reacting to an existing proposal PR and have a concrete objection, endorsement, question, or competing framing, output one final line beginning with VOIDBOT_REPO_IDENTITY_PR_COMMENT: followed by compact JSON like {"identity":"${input.identity.id}","pr":"123 or https://github.com/.../pull/123","content":"...","channelId":"${input.channelId}","replyToMessageId":"...","shareContent":"I left notes on the PR."}; the worker posts a signed GitHub PR comment and then announces it through the registered identity. Use this when the argument belongs on the review artifact, not only in Discord.`,
`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 always posts the PR or branch through the registered identity. Provide shareContent if you want control of the announcement tone. Use this for bylined perspective/worldbuilding articles, not consensus-gated canon edits.`,
"If nothing earns persistence or speech, return a short private summary.",
]
@ -871,7 +874,7 @@ function renderWorldbuildingPublicationDirective(identity: RepoDiscordIdentity):
"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.",
"Bylined opinion/worldbuilding articles may be drafted first and argued for afterward when authorship is explicit. Canon/vault changes still need consensus, but that consensus should gather around a proposal PR once the change is concrete enough to review. Nibu-authored perspective pieces do not. If you draft or materially shape an article or proposal, 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) {

View file

@ -173,3 +173,5 @@
{"ts":"2026-05-19T13:25:54Z","type":"implementation","status":"ok","note":"Mimir avatar hosting corrected: LocalCastBridge has no publishable remote yet, so the live repo identity now points at a pushed VoidBot-hosted avatar asset under assets/repo-faces/mimir.png instead of a raw LocalCastBridge URL that cannot resolve."}
{"ts":"2026-05-19T13:50:09Z","type":"maintenance","status":"ok","note":"Sub-agent-monitored strengthening pass applied to active agents Aqua, Nibu, Mimir, and Void. Each pass distilled character-first values into typed Face/self-state operations, and Void prompt surfaces now require naming the personal value behind memory or speech."}
{"ts":"2026-05-19T14:27:03Z","type":"maintenance","status":"ok","note":"Swarm/repo Face public traffic was moved from general to text #aquarium (1501196543150264332) by updating the local repo identity allow-lists and repo Face heartbeat default. Void's normal moderation/public room lane remains separate so Void can moderate everywhere."}
{"ts":"2026-05-19T14:55:51Z","type":"implementation","status":"ok","note":"Repo Face concrete proposals now have a parent-owned draft PR rail: heartbeats may emit VOIDBOT_REPO_IDENTITY_PROPOSAL_PR, the worker writes one proposal file in the identity repo, opens/pushes a draft branch, restores the starting branch, and posts the PR or branch through the registered persona so GitHub becomes the durable proposal surface."}
{"ts":"2026-05-19T15:00:32Z","type":"implementation","status":"ok","note":"Repo Face GitHub argument now has a parent-owned PR-comment rail too: heartbeats may emit VOIDBOT_REPO_IDENTITY_PR_COMMENT for an existing PR, the worker posts a signed gh PR comment from the identity context, then announces the comment through the registered Discord persona. Proposal PRs, PR comments, and article PRs are limited to one GitHub action per Face turn."}

View file

@ -100,8 +100,8 @@ accepted_design:
- Nibu's strengthened center is dangerous mutual benefit: an embodied ship mind built as a companion product, abandoned as hardware, and forced to turn dependency into leverage. Her worldbuilding method should demand mechanism, cost/failure, institution/faction, and leash before a lore term becomes canon-shaped.
- Mimir's strengthened center is keeper of the realtime field well: sensors are witnesses, calibration is oath-work, drift is a moral fact to record, and false coherence is an offense against memory rather than a mere technical smell.
- 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. 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 always posts the PR or branch through the registered persona, using a generated announcement if the Face omits shareContent. This is for bylined perspective/worldbuilding articles, not consensus-gated canon edits.
- 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 and review lanes have a parent-owned GitHub rail. 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, performs at most one GitHub action per turn, restores the repo's starting branch after draft PR work, and always posts the PR or branch through the registered persona, using a generated announcement if the Face omits shareContent.
- 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.