Add Bifrost governance topic threads
This commit is contained in:
parent
a0fd762d98
commit
163a6032ad
5 changed files with 642 additions and 7 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
# Local Bifrost transport/state stores
|
# Local Bifrost transport/state stores
|
||||||
.bifrost/
|
.bifrost/
|
||||||
|
.voidbot/
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
*.rsuser
|
*.rsuser
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core
|
||||||
- health and readiness endpoints plus startup config validation
|
- health and readiness endpoints plus startup config validation
|
||||||
- GitHub App webhook ingestion for issue, pull request, and review sync
|
- GitHub App webhook ingestion for issue, pull request, and review sync
|
||||||
- local bridge tooling for agent-owned GitHub draft PRs and Discord posts
|
- local bridge tooling for agent-owned GitHub draft PRs and Discord posts
|
||||||
|
- CultCache-backed governance topic threads for feature requests, discussion comments, Face approvals, and dispatch promotion
|
||||||
- CultCache/CultNet-backed agent intake tooling for repo Face update requests
|
- CultCache/CultNet-backed agent intake tooling for repo Face update requests
|
||||||
- Docker image and local Compose stack for containerized smoke testing
|
- Docker image and local Compose stack for containerized smoke testing
|
||||||
- xUnit integration tests under `tests/Bifrost.Web.Tests`
|
- xUnit integration tests under `tests/Bifrost.Web.Tests`
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
# Agent Transport
|
# Agent Transport
|
||||||
|
|
||||||
Bifrost owns the agent transport contract for GameCult work requests because agent intake is part of Bifrost's public-process jurisdiction. The broader ownership rule lives in `docs/jurisdiction-map.md`: Bifrost governs work crossings; CultCache stores typed packets; CultNet moves them; VoidBot observes Discord and packages consensus; repo Faces claim only work inside their jurisdiction.
|
Bifrost owns the agent transport contract for GameCult work requests because agent intake is part of Bifrost's public-process jurisdiction. The broader ownership rule lives in `docs/jurisdiction-map.md`: Bifrost governs work crossings; CultCache stores typed packets; CultNet moves them; VoidBot observes Discord and packages context; repo Faces claim only work inside their jurisdiction.
|
||||||
|
|
||||||
|
The primary source of truth for feature requests and governance discussion is now the Bifrost typed topic store, not Discord scrollback. Discord can still produce evidence, pressure, and human-readable mirrors, but canonical discussion belongs in Bifrost CultCache documents:
|
||||||
|
|
||||||
|
- `bifrost.governance.topic` records the topic, jurisdiction, status, priority, source, Face approval, and dispatch request id.
|
||||||
|
- `bifrost.governance.topic-comment` records human, agent, Face, and system comments on that topic.
|
||||||
|
- `bifrost.agent-transport.update-request` is the dispatch packet produced after the topic has enough shape and the owning Face approves it.
|
||||||
|
|
||||||
|
That split keeps the live machine honest. Discussion lives in topic/comment documents. Dispatch lives in update-request documents. Discord mirrors and supplies input; it does not become the hidden parliament because everyone happened to be typing there.
|
||||||
|
|
||||||
This is not a Postgres queue and not a VoidBot side channel. VoidBot may observe Discord and package consensus, but the shared request lane belongs here:
|
This is not a Postgres queue and not a VoidBot side channel. VoidBot may observe Discord and package consensus, but the shared request lane belongs here:
|
||||||
|
|
||||||
|
|
@ -11,7 +19,7 @@ This is not a Postgres queue and not a VoidBot side channel. VoidBot may observe
|
||||||
|
|
||||||
That ownership split matters. If the queue lives in VoidBot, Discord becomes the hidden source of authority. If it lives in Bifrost but stores through Entity Framework, agents cannot share the same packet through the CultCache/CultNet machinery they already use for state. The coherent machine is smaller: Bifrost says what the request means; CultCache remembers it; CultNet moves it.
|
That ownership split matters. If the queue lives in VoidBot, Discord becomes the hidden source of authority. If it lives in Bifrost but stores through Entity Framework, agents cannot share the same packet through the CultCache/CultNet machinery they already use for state. The coherent machine is smaller: Bifrost says what the request means; CultCache remembers it; CultNet moves it.
|
||||||
|
|
||||||
An update request is not just a message in a queue. It is a governed handoff:
|
An update request is not just a message in a queue. It is the governed handoff after a topic has become actionable:
|
||||||
|
|
||||||
- what changed in the room, repo, motion, or work context
|
- what changed in the room, repo, motion, or work context
|
||||||
- which repo or Face has jurisdiction
|
- which repo or Face has jurisdiction
|
||||||
|
|
@ -21,6 +29,47 @@ An update request is not just a message in a queue. It is a governed handoff:
|
||||||
|
|
||||||
## Document
|
## Document
|
||||||
|
|
||||||
|
## Governance Topics
|
||||||
|
|
||||||
|
Canonical feature and governance discussion uses `tools/governance-threads.mjs`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node .\tools\governance-threads.mjs open `
|
||||||
|
--repo AquaSynth `
|
||||||
|
--agent aqua `
|
||||||
|
--title "Universal utterance schema with Weksa" `
|
||||||
|
--summary-file E:\path\summary.md `
|
||||||
|
--priority 86 `
|
||||||
|
--source-kind discord_consensus `
|
||||||
|
--source-channel-id 1501196543150264332
|
||||||
|
|
||||||
|
node .\tools\governance-threads.mjs comment `
|
||||||
|
--topic topic_... `
|
||||||
|
--author libby `
|
||||||
|
--author-kind face `
|
||||||
|
--stance support `
|
||||||
|
--body "Keep intent, embedding, and automation lowering inspectable."
|
||||||
|
|
||||||
|
node .\tools\governance-threads.mjs approve `
|
||||||
|
--topic topic_... `
|
||||||
|
--approved-by aqua `
|
||||||
|
--body "Aqua approves dispatch once AquaSynth owns only the explicit tract/automation lowering contract."
|
||||||
|
|
||||||
|
node .\tools\governance-threads.mjs promote --topic topic_...
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent heartbeat prompts should receive a digest from:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node .\tools\governance-threads.mjs digest --repo AquaSynth --agent aqua
|
||||||
|
```
|
||||||
|
|
||||||
|
Agents should post opinions, objections, support, questions, approvals, and receipts to Bifrost topics. Discord posts about governed work should be mirrors or concise human-facing pointers back to the canonical Bifrost topic.
|
||||||
|
|
||||||
|
The planned `#bifrost` Discord channel is a mirror and human interface. Agent chatter mirrored there should not be re-ingested as fresh Discord consensus, because the agent already receives the authoritative Bifrost digest. Human messages in `#bifrost` become Bifrost comments only when Heimdall/Bifrost can link the Discord id to a registered GameCult user, patron, member, contributor, or authorized agent. Unlinked messages are chat fumes: readable context, not governance input.
|
||||||
|
|
||||||
Agent update requests are stored as CultCache documents with this type:
|
Agent update requests are stored as CultCache documents with this type:
|
||||||
|
|
||||||
`bifrost.agent-transport.update-request`
|
`bifrost.agent-transport.update-request`
|
||||||
|
|
|
||||||
|
|
@ -44,15 +44,17 @@ If the bridge lives in VoidBot, Discord becomes a hidden governance surface. If
|
||||||
|
|
||||||
## Discord Decision
|
## Discord Decision
|
||||||
|
|
||||||
Discord should become a native Bifrost interface, and Bifrost should own Discord transport authority for Bifrost-scoped work and swarm I/O.
|
Discord should become a native Bifrost interface, and Bifrost should own Discord transport authority for Bifrost-scoped work and swarm I/O. It should not be the canonical database of feature requests or governance discussion.
|
||||||
|
|
||||||
That means Bifrost should eventually provide Discord access to registered GameCult users, patrons, members, and contributors. A Discord message can become a priority signal, vote prompt, work request, claim update, bounty/reward discussion, maintainer review notice, or agent dispatch event when the actor and channel are authorized.
|
That means Bifrost should eventually provide Discord access to registered GameCult users, patrons, members, and contributors. A Discord message can become a priority signal, vote prompt, work request, claim update, bounty/reward discussion, maintainer review notice, or agent dispatch event when the actor and channel are authorized.
|
||||||
|
|
||||||
It also means Bifrost should become the router for Discord input/output for the agent swarm:
|
It also means Bifrost should become the router for Discord input/output for the agent swarm:
|
||||||
|
|
||||||
- inbound Discord mentions or channel events that target a Face become Bifrost-routed work or speech events
|
- inbound Discord mentions or channel events that target a Face become Bifrost-routed work, speech, or governance-comment events when authorized
|
||||||
- outbound agent posts, PR receipts, article announcements, and dispatch acknowledgements go through Bifrost
|
- outbound agent posts, PR receipts, article announcements, and dispatch acknowledgements go through Bifrost
|
||||||
- Bifrost records which Face spoke, why the message was allowed, what request or work item it relates to, and where the receipt landed
|
- Bifrost records which Face spoke, why the message was allowed, what topic, request, or work item it relates to, and where the receipt landed
|
||||||
|
- a dedicated `#bifrost` Discord channel can mirror Bifrost topic activity for human readability, but mirrored agent messages there are not re-ingested as new consensus
|
||||||
|
- human `#bifrost` messages become canonical Bifrost comments only after Heimdall/Bifrost links the Discord id to a registered actor; unlinked messages remain non-governance chat context
|
||||||
|
|
||||||
This does not mean Bifrost owns all Discord behavior. General conversation, room reading, moderation judgment, archive retrieval, and personality cognition remain VoidBot/Face concerns. Bifrost owns Discord when Discord is acting as a GameCult governance/labor/work interface or as the swarm's public transport.
|
This does not mean Bifrost owns all Discord behavior. General conversation, room reading, moderation judgment, archive retrieval, and personality cognition remain VoidBot/Face concerns. Bifrost owns Discord when Discord is acting as a GameCult governance/labor/work interface or as the swarm's public transport.
|
||||||
|
|
||||||
|
|
@ -62,6 +64,8 @@ This does not mean Bifrost owns all Discord behavior. General conversation, room
|
||||||
|
|
||||||
- bridge action request shape and lifecycle
|
- bridge action request shape and lifecycle
|
||||||
- work-priority intake packets and claim semantics
|
- work-priority intake packets and claim semantics
|
||||||
|
- canonical feature-request and governance topic threads
|
||||||
|
- topic comments, support, objections, questions, Face approvals, dispatch promotion, and topic receipts
|
||||||
- user, patron, member, contributor, and agent transport events when they affect work, priority, reward, votes, claims, or receipts
|
- user, patron, member, contributor, and agent transport events when they affect work, priority, reward, votes, claims, or receipts
|
||||||
- mapping from internal request to public target surface
|
- mapping from internal request to public target surface
|
||||||
- GitHub proposal/comment/PR transport for agent and member work
|
- GitHub proposal/comment/PR transport for agent and member work
|
||||||
|
|
@ -117,7 +121,7 @@ Repo Faces may generate bridge requests. They do not own the bridge itself.
|
||||||
| What work is being requested? | Bifrost | The request shape, priority, provenance, and lifecycle are governance concerns. |
|
| What work is being requested? | Bifrost | The request shape, priority, provenance, and lifecycle are governance concerns. |
|
||||||
| How does interest become reward pressure? | Bifrost | Priority, bounty growth, vote weight, and reward allocation are Labor Platform concerns. |
|
| How does interest become reward pressure? | Bifrost | Priority, bounty growth, vote weight, and reward allocation are Labor Platform concerns. |
|
||||||
| Where is the durable argument surface? | Bifrost | Public process must land on the correct GitHub, Discord, or future collaboration surface. |
|
| Where is the durable argument surface? | Bifrost | Public process must land on the correct GitHub, Discord, or future collaboration surface. |
|
||||||
| What did the room recently agree or ask for? | VoidBot | Discord observation and consensus packaging belong to the room-aware bot. |
|
| What did the room recently agree or ask for? | VoidBot into Bifrost | Discord observation belongs to the room-aware bot; governed consensus is stored as Bifrost topics/comments. |
|
||||||
| Is Discord acting as a Bifrost interface? | Bifrost | Member access, vote prompts, work requests, priority signals, reward discussion, and swarm routing are native Bifrost transport concerns. |
|
| Is Discord acting as a Bifrost interface? | Bifrost | Member access, vote prompts, work requests, priority signals, reward discussion, and swarm routing are native Bifrost transport concerns. |
|
||||||
| What does the repo Face think should happen? | Repo Face | Jurisdictional taste and proposal pressure belong to the Face. |
|
| What does the repo Face think should happen? | Repo Face | Jurisdictional taste and proposal pressure belong to the Face. |
|
||||||
| Where is the packet stored and synced? | CultCache/CultNet | Transport state should use the shared typed storage/sync substrate. |
|
| Where is the packet stored and synced? | CultCache/CultNet | Transport state should use the shared typed storage/sync substrate. |
|
||||||
|
|
@ -157,7 +161,8 @@ This is not the final hosted control plane. It is the current bridge actuator an
|
||||||
- Do not put OAuth credentials or account custody into Bifrost helper scripts as the long-term plan. Use Heimdall claims.
|
- Do not put OAuth credentials or account custody into Bifrost helper scripts as the long-term plan. Use Heimdall claims.
|
||||||
- Do not make CultCache documents decide policy. They store packets; Bifrost gives those packets meaning.
|
- Do not make CultCache documents decide policy. They store packets; Bifrost gives those packets meaning.
|
||||||
- Do not let a repo Face turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact.
|
- Do not let a repo Face turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact.
|
||||||
- Do not expose bridge debug noise to public channels. Public receipts should say what happened, where it landed, and what comes next.
|
- Do not expose bridge debug noise to public channels. Public receipts should say what happened, which Bifrost topic or work item owns it, where it landed, and what comes next.
|
||||||
|
- Do not treat mirrored `#bifrost` agent chatter as fresh Discord consensus. Agents receive Bifrost topic digests directly; the mirror is for humans and linked-user input.
|
||||||
- Do not make Bifrost responsible for generic chat cognition, lore retrieval, moderation taste, or Face personality. Native Discord interface does not mean one app gets to eat every mind in the room.
|
- Do not make Bifrost responsible for generic chat cognition, lore retrieval, moderation taste, or Face personality. Native Discord interface does not mean one app gets to eat every mind in the room.
|
||||||
|
|
||||||
## Future Shape
|
## Future Shape
|
||||||
|
|
|
||||||
579
tools/governance-threads.mjs
Normal file
579
tools/governance-threads.mjs
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
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 cultCacheRequire = createRequire(resolve(projectsRoot, "CultCacheTS", "package.json"));
|
||||||
|
const {
|
||||||
|
CultCache,
|
||||||
|
SingleFileMessagePackBackingStore,
|
||||||
|
defineDocumentType,
|
||||||
|
} = cultCacheRequire(resolve(projectsRoot, "CultCacheTS", "dist", "index.js"));
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const [command, ...rawArgs] = process.argv.slice(2);
|
||||||
|
const options = parseArgs(rawArgs);
|
||||||
|
|
||||||
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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 Face (${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);
|
||||||
|
|
||||||
|
if (approvalBody) {
|
||||||
|
const 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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("--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);
|
||||||
|
|
||||||
|
printJson({ topic: dispatched, request });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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 Face 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_...
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue