#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { commandIdFromOptions, crossingProvenanceFromOptions, writeCrossingReceipt, } from "./bifrost-crossing-documents.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); const projectsRoot = resolve(repoRoot, ".."); const defaultStorePath = resolve(repoRoot, ".bifrost", "governance-threads.cc"); const defaultPacketDir = resolve(repoRoot, ".bifrost", "governance-dispatch-packets"); const agentTransportCli = resolve(repoRoot, "tools", "agent-transport.mjs"); const bridgeCli = resolve(repoRoot, "tools", "bifrost-bridge.mjs"); const defaultPersonaName = "Bifrost"; const defaultPersonaAvatarUrl = "https://raw.githubusercontent.com/GameCult/Bifrost/main/src/Bifrost.Web/wwwroot/img/bifrost-profile.png"; const cultCachePackagePath = resolveFirstExisting("CultCache TypeScript runtime", [ resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"), resolve(projectsRoot, "CultCacheTS", "package.json"), resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json"), ]); const cultCacheRequire = createRequire(cultCachePackagePath); const { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, } = cultCacheRequire("@gamecult/cultcache-ts"); function resolveFirstExisting(label, candidates) { for (const candidate of candidates) { if (existsSync(candidate)) { return candidate; } } throw new Error(`${label} is unavailable. Tried: ${candidates.join(", ")}`); } const topicType = "bifrost.governance.topic"; const topicSchemaId = "bifrost.governance.topic.v0"; const commentType = "bifrost.governance.topic-comment"; const commentSchemaId = "bifrost.governance.topic_comment.v0"; const validTopicStatuses = new Set(["open", "consensus_ready", "approved", "dispatched", "closed", "cancelled"]); const validStances = new Set(["comment", "proposal", "support", "objection", "question", "approval", "summary", "receipt"]); const topicDefinition = defineDocumentType({ type: topicType, schemaId: topicSchemaId, schemaName: topicType, schemaVersion: "bifrost.governance.topic.v0", schema: { parse: parseTopic }, name: "id", indexes: { jurisdictionRepoName: "jurisdictionRepoName", jurisdictionAgentIdentity: "jurisdictionAgentIdentity", status: "status", }, members: [ { slot: 1, memberName: "id", typeName: "string", isName: true }, { slot: 2, memberName: "title", typeName: "string" }, { slot: 3, memberName: "jurisdictionRepoName", typeName: "string", indexAlias: "jurisdictionRepoName" }, { slot: 4, memberName: "jurisdictionAgentIdentity", typeName: "string", indexAlias: "jurisdictionAgentIdentity" }, { slot: 5, memberName: "status", typeName: "string", indexAlias: "status" }, { slot: 6, memberName: "summaryMarkdown", typeName: "string" }, { slot: 7, memberName: "priority", typeName: "number" }, { slot: 8, memberName: "sourceKind", typeName: "string" }, { slot: 9, memberName: "sourceChannelId", typeName: "string" }, { slot: 10, memberName: "sourceMessageIds", typeName: "string", isMany: true }, { slot: 11, memberName: "createdByActor", typeName: "string" }, { slot: 12, memberName: "approvedByAgent", typeName: "string" }, { slot: 13, memberName: "dispatchRequestId", typeName: "string" }, { slot: 14, memberName: "createdAt", typeName: "string" }, { slot: 15, memberName: "updatedAt", typeName: "string" }, { slot: 16, memberName: "approvedAt", typeName: "string" }, { slot: 17, memberName: "dispatchedAt", typeName: "string" }, { slot: 18, memberName: "closedAt", typeName: "string" }, ], }); const commentDefinition = defineDocumentType({ type: commentType, schemaId: commentSchemaId, schemaName: commentType, schemaVersion: "bifrost.governance.topic_comment.v0", schema: { parse: parseComment }, name: "id", indexes: { topicId: "topicId", authorId: "authorId", stance: "stance", }, members: [ { slot: 1, memberName: "id", typeName: "string", isName: true }, { slot: 2, memberName: "topicId", typeName: "string", indexAlias: "topicId" }, { slot: 3, memberName: "authorKind", typeName: "string" }, { slot: 4, memberName: "authorId", typeName: "string", indexAlias: "authorId" }, { slot: 5, memberName: "stance", typeName: "string", indexAlias: "stance" }, { slot: 6, memberName: "bodyMarkdown", typeName: "string" }, { slot: 7, memberName: "sourceMessageId", typeName: "string" }, { slot: 8, memberName: "createdAt", typeName: "string" }, ], }); async function main() { loadBifrostLocalEnv(); const [command, ...rawArgs] = process.argv.slice(2); const options = parseArgs(rawArgs); if (!command || command === "help" || command === "--help" || command === "-h") { printHelp(); return; } if (isMutatingCommand(command)) { ensureGovernanceReceiptGate(options); } const storePath = resolveOptionPath(options.store ?? defaultStorePath); const cache = await openCache(storePath); switch (command) { case "open": await openTopic(cache, options); return; case "comment": await addComment(cache, options); return; case "approve": await approveTopic(cache, options); return; case "promote": await promoteTopic(cache, options); return; case "list": listTopics(cache, options); return; case "show": showTopic(cache, options); return; case "digest": digestTopics(cache, options); return; case "schema": printJson({ topicType, topicSchemaId, commentType, commentSchemaId }); return; default: throw new Error(`Unknown command "${command}". Run "node tools/governance-threads.mjs help".`); } } function loadBifrostLocalEnv() { if (process.env.BIFROST_SKIP_LOCAL_ENV === "true") { return; } loadLocalEnv(resolve(repoRoot, ".env")); loadLocalEnv(resolve(projectsRoot, "VoidBot", ".env")); } async function openCache(storePath) { const cache = CultCache.builder() .withDocumentType(topicDefinition) .withDocumentType(commentDefinition) .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) .build(); await cache.pullAllBackingStores(); return cache; } async function openTopic(cache, options) { const now = new Date().toISOString(); const topic = parseTopic({ id: options.id ?? `topic_${randomUUID()}`, title: requireOption(options, "title"), jurisdictionRepoName: requireOption(options, "repo"), jurisdictionAgentIdentity: optionalString(options.agent), status: optionalString(options.status) ?? "open", summaryMarkdown: await readMarkdownOption(options, "summary"), priority: parseInteger(options.priority ?? "50", "priority"), sourceKind: optionalString(options["source-kind"]) ?? "manual", sourceChannelId: optionalString(options["source-channel-id"]), sourceMessageIds: parseCsv(options["source-message-ids"]), createdByActor: optionalString(options["created-by"]), approvedByAgent: undefined, dispatchRequestId: undefined, createdAt: now, updatedAt: now, approvedAt: undefined, dispatchedAt: undefined, closedAt: undefined, }); await cache.put(topicDefinition, topic.id, topic); try { await mirrorTopicActivityOrThrow(cache, topic, { options, fallbackContent: topic.summaryMarkdown, eventLabel: "opened", }); await recordGovernanceReceiptOrThrow(topic, { commentId: "", dispatchRequestId: "", activityKind: "TopicOpened", actorKind: "agent", actorName: topic.createdByActor ?? "system", note: "Governance topic opened.", }, options); } catch (error) { await cache.delete(topicDefinition, topic.id); throw error; } printJson(topic); } async function addComment(cache, options) { const topicId = requireOption(options, "topic"); const topic = cache.get(topicDefinition, topicId); if (!topic) { throw new Error(`No governance topic found for id "${topicId}".`); } const comment = parseComment({ id: options.id ?? `comment_${randomUUID()}`, topicId, authorKind: optionalString(options["author-kind"]) ?? "agent", authorId: requireOption(options, "author"), stance: optionalString(options.stance) ?? "comment", bodyMarkdown: await readMarkdownOption(options, "body"), sourceMessageId: optionalString(options["source-message-id"]), createdAt: new Date().toISOString(), }); await cache.put(commentDefinition, comment.id, comment); try { await mirrorTopicActivityOrThrow(cache, topic, { options, fallbackContent: comment.bodyMarkdown, eventLabel: comment.stance, }); await recordGovernanceReceiptOrThrow(topic, { commentId: comment.id, dispatchRequestId: "", activityKind: "TopicCommented", actorKind: comment.authorKind, actorName: comment.authorId, note: `Governance topic ${comment.stance} comment recorded.`, }, options); } catch (error) { await cache.delete(commentDefinition, comment.id); throw error; } printJson(comment); } async function approveTopic(cache, options) { const topic = requireTopic(cache, options); const approvedBy = requireOption(options, "approved-by"); const approvalBody = await readOptionalMarkdownOption(options, "body"); if (!topic.jurisdictionAgentIdentity || !equalsIgnoreCase(approvedBy, topic.jurisdictionAgentIdentity)) { throw new Error(`Topic "${topic.id}" can only be approved by its jurisdiction Persona (${topic.jurisdictionAgentIdentity ?? "none"}).`); } const now = new Date().toISOString(); const approved = parseTopic({ ...topic, status: "approved", approvedByAgent: approvedBy, approvedAt: now, updatedAt: now, }); await cache.put(topicDefinition, approved.id, approved); let comment; if (approvalBody) { comment = parseComment({ id: `comment_${randomUUID()}`, topicId: topic.id, authorKind: "face", authorId: approvedBy, stance: "approval", bodyMarkdown: approvalBody, sourceMessageId: optionalString(options["source-message-id"]), createdAt: now, }); await cache.put(commentDefinition, comment.id, comment); } try { await mirrorTopicActivityOrThrow(cache, approved, { options, fallbackContent: approvalBody ?? `Approved by ${approvedBy}.`, eventLabel: "approved", }); await recordGovernanceReceiptOrThrow(approved, { commentId: comment?.id ?? "", dispatchRequestId: "", activityKind: "TopicApproved", actorKind: "face", actorName: approvedBy, note: "Governance topic approved.", }, options); } catch (error) { await cache.put(topicDefinition, topic.id, topic); if (comment) { await cache.delete(commentDefinition, comment.id); } throw error; } printJson(approved); } async function promoteTopic(cache, options) { const topic = requireTopic(cache, options); if (topic.status !== "approved") { throw new Error(`Topic "${topic.id}" must be approved before dispatch promotion; current status is ${topic.status}.`); } const comments = commentsForTopic(cache, topic.id); const requestMarkdown = renderUpdateRequest(topic, comments); const packetDir = resolveOptionPath(options["packet-dir"] ?? defaultPacketDir); await mkdir(packetDir, { recursive: true }); const packetPath = resolve(packetDir, `${topic.id}.md`); await writeFile(packetPath, requestMarkdown, "utf8"); const request = runNodeJson([ agentTransportCli, "enqueue", "--repo", topic.jurisdictionRepoName, ...optionalArg("--agent", topic.jurisdictionAgentIdentity), "--title", topic.title, "--request-file", packetPath, "--priority", String(topic.priority), "--source-kind", "bifrost_governance_topic", ...optionalArg("--source-channel-id", topic.sourceChannelId), ...optionalArg("--source-message-ids", topic.sourceMessageIds.join(",")), "--packet-path", packetPath, ...optionalArg("--created-by", topic.approvedByAgent ?? topic.createdByActor), ...optionalArg("--mirror-channel-id", resolveMirrorChannelId(options)), ...optionalArg("--mirror-persona-name", optionalString(process.env.BIFROST_DISCORD_PERSONA_NAME) ?? defaultPersonaName), ...optionalArg( "--mirror-persona-avatar-url", optionalString(process.env.BIFROST_DISCORD_PERSONA_AVATAR_URL) ?? optionalString(process.env.DISCORD_PERSONA_AVATAR_URL_BIFROST) ?? defaultPersonaAvatarUrl, ), ...(options["mirror-dry-run"] === "true" ? ["--mirror-dry-run", "true"] : []), ...(allowsUnmirrored(options) ? ["--allow-unmirrored", "true"] : []), ...optionalArg("--store", options["transport-store"]), ], repoRoot); const now = new Date().toISOString(); const dispatched = parseTopic({ ...topic, status: "dispatched", dispatchRequestId: request.id, dispatchedAt: now, updatedAt: now, }); await cache.put(topicDefinition, dispatched.id, dispatched); const receipt = parseComment({ id: `comment_${randomUUID()}`, topicId: topic.id, authorKind: "system", authorId: "bifrost", stance: "receipt", bodyMarkdown: `Promoted to Bifrost update request \`${request.id}\`.`, sourceMessageId: undefined, createdAt: now, }); await cache.put(commentDefinition, receipt.id, receipt); try { await mirrorTopicActivityOrThrow(cache, dispatched, { options, fallbackContent: receipt.bodyMarkdown, eventLabel: "dispatched", }); await recordGovernanceReceiptOrThrow(dispatched, { commentId: receipt.id, dispatchRequestId: request.id, activityKind: "TopicPromoted", actorKind: "system", actorName: topic.approvedByAgent ?? topic.createdByActor ?? "bifrost", note: `Governance topic promoted to update request ${request.id}.`, }, options); } catch (error) { await cache.put(topicDefinition, topic.id, topic); await cache.delete(commentDefinition, receipt.id); throw error; } printJson({ topic: dispatched, request }); } async function mirrorTopicActivityOrThrow(cache, topic, input) { const channelId = resolveMirrorChannelId(input.options); if (!channelId) { if (allowsUnmirrored(input.options)) { return; } throw new Error( "Bifrost governance writes require a Discord mirror. Set BIFROST_DISCORD_CHANNEL_ID, pass --mirror-channel-id, or use --allow-unmirrored true only for explicit fixtures.", ); } const personaName = optionalString(input.options["mirror-persona-name"]) ?? optionalString(process.env.BIFROST_DISCORD_PERSONA_NAME) ?? defaultPersonaName; const personaAvatarUrl = optionalString(input.options["mirror-persona-avatar-url"]) ?? optionalString(process.env.BIFROST_DISCORD_PERSONA_AVATAR_URL) ?? optionalString(process.env.DISCORD_PERSONA_AVATAR_URL_BIFROST) ?? defaultPersonaAvatarUrl; const mirrorContent = renderGovernanceMirrorContent( topic, input.eventLabel, await readOptionalMarkdownOption(input.options, "mirror-content") ?? input.fallbackContent, ); const now = new Date().toISOString(); const receipt = runNodeJson([ bridgeCli, "discord-post", "--channel-id", channelId, "--content", mirrorContent, "--persona-name", personaName, "--source-kind", "bifrost_governance_topic", "--source-id", topic.id, "--authority-ref", `bifrost_governance_${input.eventLabel}`, "--target-repository-full-name", topic.jurisdictionRepoName, ...optionalArg("--persona-avatar-url", personaAvatarUrl), ...optionalArg("--reply-to-message-id", input.options["mirror-reply-to-message-id"]), ...bridgeRecoveryArgs(input.options), ...(input.options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []), ], repoRoot); const bodyMarkdown = receipt.dryRun ? `Dry-run mirror prepared for Discord channel ${channelId}.` : `Mirrored ${input.eventLabel} to Discord channel ${channelId}${receipt.url ? `: ${receipt.url}` : ""}.`; const receiptComment = parseComment({ id: `comment_${randomUUID()}`, topicId: topic.id, authorKind: "system", authorId: "bifrost", stance: "receipt", bodyMarkdown, sourceMessageId: receipt.messageId, createdAt: now, }); await cache.put(commentDefinition, receiptComment.id, receiptComment); } function resolveMirrorChannelId(options) { return ( optionalString(options["mirror-channel-id"]) ?? optionalString(process.env.BIFROST_DISCORD_CHANNEL_ID) ?? optionalString(process.env.DISCORD_BIFROST_CHANNEL_ID) ); } function allowsUnmirrored(options) { return options["allow-unmirrored"] === "true" || process.env.BIFROST_ALLOW_UNMIRRORED_GOVERNANCE === "true"; } function bridgeRecoveryArgs(options) { return []; } function renderGovernanceMirrorContent(topic, eventLabel, content) { const event = governanceMirrorEvent(eventLabel); return [ `Bifrost governance: ${event.label}`, "", `**${topic.title}**`, `Topic: \`${topic.id}\``, `Jurisdiction: ${topic.jurisdictionRepoName}${topic.jurisdictionAgentIdentity ? ` / ${topic.jurisdictionAgentIdentity}` : ""}`, `Topic status: ${topic.status}`, `Codex job: ${event.codexJob}`, "", truncateMarkdown(content.trim(), 1200), "", event.nextStep, ].join("\n"); } function governanceMirrorEvent(eventLabel) { switch (eventLabel) { case "opened": return { label: "topic opened", codexJob: "no - this is discussion/proposal intake only", nextStep: "Next: comments, jurisdiction review, or approval. Nothing has been dispatched.", }; case "approved": return { label: "topic approved", codexJob: "no - approved topics still need promotion before dispatch", nextStep: "Next: promotion creates a Bifrost update request. A separate dispatch receipt means Codex actually started.", }; case "dispatched": return { label: "topic promoted to update request", codexJob: "queued - Codex has not necessarily started yet", nextStep: "Next: wait for a `Bifrost Codex dispatch started` receipt before treating this as an active Codex job.", }; default: return { label: `topic ${eventLabel}`, codexJob: "no - this is governance discussion, not dispatch", nextStep: "Next: keep discussing, approve, or promote when the jurisdiction owner is ready.", }; } } function truncateMarkdown(value, maxLength) { return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; } async function recordGovernanceReceiptOrThrow(topic, receipt, options = {}) { const commandId = commandIdFromOptions(options) || `governance:${topic.id}:${receipt.activityKind}`; const provenance = crossingProvenanceFromOptions({ ...options, "cultmesh-command-id": commandId, "source-kind": "bifrost.governance.topic", "source-id": topic.id, "topic-id": topic.id, "authority-ref": `bifrost_governance_${receipt.activityKind}`, identity: receipt.actorName, }); const now = new Date().toISOString(); await writeCrossingReceipt({ receiptId: `crossing_${slugifyReceiptPart(commandId)}_${slugifyReceiptPart(receipt.activityKind)}`, commandId, crossingKind: "governance.topic", status: topic.status === "cancelled" ? "cancelled" : "completed", ok: true, requestedAt: topic.createdAt || now, startedAt: topic.updatedAt || now, completedAt: now, actor: { kind: receipt.actorKind || "system", name: receipt.actorName || "bifrost", bifrostIdentity: receipt.actorName || "bifrost", }, source: provenance.source, authority: provenance.authority, epiphany: provenance.epiphany, target: { surface: "governance", locator: topic.id, repositoryFullName: topic.jurisdictionRepoName, topicId: topic.id, dispatchRequestId: receipt.dispatchRequestId || "", status: topic.status, }, externalReceipt: { id: receipt.commentId || receipt.dispatchRequestId || topic.id, transport: "cultcache", url: "", }, surfaceReceipt: { activityKind: receipt.activityKind, note: receipt.note || "", commentId: receipt.commentId || "", dispatchRequestId: receipt.dispatchRequestId || "", }, }, options); } function isMutatingCommand(command) { return command === "open" || command === "comment" || command === "approve" || command === "promote"; } function ensureGovernanceReceiptGate(options) { if (wantsUnreceiptedActivity(options)) { throw new Error( "Bifrost governance mutations cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " + "Use the typed Bifrost CultCache/CultMesh governance store instead of mutating off-book.", ); } if (hasRemovedHttpBridgeConfig()) { throw new Error( "BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " + "Bifrost governance receipts are written to the typed CultCache/CultMesh store.", ); } } function hasRemovedHttpBridgeConfig() { return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN)); } function wantsUnreceiptedActivity(options) { return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true"; } function listTopics(cache, options) { const topics = cache.getAll(topicDefinition) .filter((topic) => !options.status || topic.status === options.status) .filter((topic) => !options.repo || equalsIgnoreCase(topic.jurisdictionRepoName, options.repo)) .filter((topic) => !options.agent || equalsIgnoreCase(topic.jurisdictionAgentIdentity, options.agent)) .sort(compareTopics); printJson(topics); } function showTopic(cache, options) { const topic = requireTopic(cache, options); printJson({ topic, comments: commentsForTopic(cache, topic.id), }); } function digestTopics(cache, options) { const limit = parseInteger(options.limit ?? "6", "limit"); const topics = cache.getAll(topicDefinition) .filter((topic) => !options.repo || equalsIgnoreCase(topic.jurisdictionRepoName, options.repo)) .filter((topic) => !options.agent || equalsIgnoreCase(topic.jurisdictionAgentIdentity, options.agent)) .filter((topic) => !options.status || topic.status === options.status) .sort(compareRecentTopics) .slice(0, limit); printJson({ generatedAt: new Date().toISOString(), topics: topics.map((topic) => ({ ...topic, comments: commentsForTopic(cache, topic.id).slice(-5), })), }); } function requireTopic(cache, options) { const id = requireOption(options, "topic"); const topic = cache.get(topicDefinition, id); if (!topic) { throw new Error(`No governance topic found for id "${id}".`); } return topic; } function commentsForTopic(cache, topicId) { return cache.getAll(commentDefinition) .filter((comment) => comment.topicId === topicId) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); } function renderUpdateRequest(topic, comments) { const commentLines = comments.map((comment) => [ `### ${comment.stance}: ${comment.authorId}`, "", comment.bodyMarkdown, ].join("\n")); return [ `# ${topic.title}`, "", `Bifrost governance topic: ${topic.id}`, `Jurisdiction: ${topic.jurisdictionRepoName}${topic.jurisdictionAgentIdentity ? ` / ${topic.jurisdictionAgentIdentity}` : ""}`, `Approved by: ${topic.approvedByAgent}`, `Priority: ${topic.priority}`, "", "## Request", "", topic.summaryMarkdown, "", "## Discussion Record", "", commentLines.length > 0 ? commentLines.join("\n\n") : "- No comments recorded.", "", "## Dispatch Instruction", "", "Work this as the approved repo-local request produced from the canonical Bifrost topic. Preserve the topic id in any report, commit message, PR body, or follow-up note.", "", ].join("\n"); } async function readMarkdownOption(options, name) { const value = await readOptionalMarkdownOption(options, name); if (!value) { throw new Error(`Missing required option --${name} or --${name}-file.`); } return value; } async function readOptionalMarkdownOption(options, name) { const fileValue = optionalString(options[`${name}-file`]); if (fileValue) { return readFile(resolveOptionPath(fileValue), "utf8"); } return optionalString(options[name]); } function parseTopic(input) { if (!input || typeof input !== "object") { throw new Error("Governance topic must be an object."); } const topic = { id: requireString(input.id, "id"), title: requireString(input.title, "title"), jurisdictionRepoName: requireString(input.jurisdictionRepoName, "jurisdictionRepoName"), jurisdictionAgentIdentity: optionalString(input.jurisdictionAgentIdentity), status: requireString(input.status, "status"), summaryMarkdown: requireString(input.summaryMarkdown, "summaryMarkdown"), priority: requireNumber(input.priority, "priority"), sourceKind: optionalString(input.sourceKind) ?? "manual", sourceChannelId: optionalString(input.sourceChannelId), sourceMessageIds: normalizeStringArray(input.sourceMessageIds, "sourceMessageIds"), createdByActor: optionalString(input.createdByActor), approvedByAgent: optionalString(input.approvedByAgent), dispatchRequestId: optionalString(input.dispatchRequestId), createdAt: requireString(input.createdAt, "createdAt"), updatedAt: requireString(input.updatedAt, "updatedAt"), approvedAt: optionalString(input.approvedAt), dispatchedAt: optionalString(input.dispatchedAt), closedAt: optionalString(input.closedAt), }; if (!validTopicStatuses.has(topic.status)) { throw new Error(`Governance topic status "${topic.status}" is not valid.`); } return topic; } function parseComment(input) { if (!input || typeof input !== "object") { throw new Error("Governance topic comment must be an object."); } const comment = { id: requireString(input.id, "id"), topicId: requireString(input.topicId, "topicId"), authorKind: requireString(input.authorKind, "authorKind"), authorId: requireString(input.authorId, "authorId"), stance: requireString(input.stance, "stance"), bodyMarkdown: requireString(input.bodyMarkdown, "bodyMarkdown"), sourceMessageId: optionalString(input.sourceMessageId), createdAt: requireString(input.createdAt, "createdAt"), }; if (!validStances.has(comment.stance)) { throw new Error(`Governance comment stance "${comment.stance}" is not valid.`); } return comment; } function parseArgs(args) { const options = {}; for (let index = 0; index < args.length; index += 1) { const token = args[index]; if (!token.startsWith("--")) { throw new Error(`Unexpected argument "${token}".`); } const key = token.slice(2); const next = args[index + 1]; if (!next || next.startsWith("--")) { options[key] = "true"; continue; } options[key] = next; index += 1; } return options; } function compareTopics(left, right) { if (right.priority !== left.priority) { return right.priority - left.priority; } return right.createdAt.localeCompare(left.createdAt); } function compareRecentTopics(left, right) { return right.updatedAt.localeCompare(left.updatedAt); } function requireOption(options, name) { const value = optionalString(options[name]); if (!value) { throw new Error(`Missing required option --${name}.`); } return value; } function optionalString(value) { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function requireString(value, field) { const normalized = optionalString(value); if (!normalized) { throw new Error(`Governance document field "${field}" must be a non-empty string.`); } return normalized; } function requireNumber(value, field) { const number = typeof value === "number" ? value : Number(value); if (!Number.isFinite(number)) { throw new Error(`Governance document field "${field}" must be a finite number.`); } return number; } function parseInteger(value, field) { const number = Number(value); if (!Number.isInteger(number)) { throw new Error(`--${field} must be an integer.`); } return number; } function parseCsv(value) { if (typeof value !== "string" || value.trim().length === 0) { return []; } return value.split(",").map((item) => item.trim()).filter(Boolean); } function normalizeStringArray(value, field) { if (value === undefined || value === null) { return []; } if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { throw new Error(`Governance document field "${field}" must be an array of strings.`); } return value; } function optionalArg(flag, value) { const normalized = optionalString(value); return normalized ? [flag, normalized] : []; } function equalsIgnoreCase(left, right) { return typeof left === "string" && typeof right === "string" && left.toLowerCase() === right.toLowerCase(); } function slugifyReceiptPart(value) { return String(value) .trim() .toLowerCase() .replace(/[^a-z0-9._:-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 120) || "receipt"; } function runNodeJson(args, cwd) { const result = spawnSync(process.execPath, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, timeout: 30000, }); if (result.status !== 0) { const reason = result.error?.message ?? result.stderr ?? result.stdout; throw new Error(`node ${args.join(" ")} failed with ${result.status ?? "unknown"}:\n${reason}`); } return JSON.parse(result.stdout); } function loadLocalEnv(path) { if (!existsSync(path)) { return; } for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) { continue; } const separator = trimmed.indexOf("="); if (separator === -1) { continue; } const key = trimmed.slice(0, separator).trim(); if (process.env[key]) { continue; } let value = trimmed.slice(separator + 1).trim(); if ( value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ) { value = value.slice(1, -1); } process.env[key] = value; } } function resolveOptionPath(path) { return resolve(process.cwd(), path); } function printJson(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } function printHelp() { process.stdout.write(`Bifrost governance topic threads Commands: open Create a canonical Bifrost topic for a feature request or discussion comment Append a proposal, support, objection, question, approval, summary, or receipt approve Mark a topic approved by its jurisdiction Persona and optionally append approval text promote Convert an approved topic into a Bifrost agent update request list List topics, optionally filtered by --repo, --agent, --status show Print one topic with its comments digest Print recent matching topics with their latest comments for agent context schema Print document type metadata Examples: node tools/governance-threads.mjs open --repo AquaSynth --agent aqua --title "Universal utterance schema" --summary-file packet.md --priority 80 node tools/governance-threads.mjs approve --topic topic_... --approved-by aqua --body "Aqua approves dispatch." node tools/governance-threads.mjs promote --topic topic_... Mirror options: --mirror-channel-id Mirror this activity to Discord through Bifrost bridge; defaults to BIFROST_DISCORD_CHANNEL_ID --mirror-persona-name Render the mirror as the Persona/persona --mirror-persona-avatar-url Optional persona avatar for the mirror --mirror-content Optional more verbal/personality-rich mirror text --mirror-content-file Read mirror text from a file --mirror-dry-run true Exercise mirror plumbing without posting to Discord --allow-unmirrored true Fixture/debug escape hatch; production writes should not use this `); } main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; });