feat(broker+cli): topics — conversation scope within a mesh (v0.2.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Adds the third axis of mesh organization: mesh = trust boundary,
group = identity tag, topic = conversation scope. Topic-tagged
messages filter delivery by topic_member rows and persist to a
topic_message history table for back-scroll on reconnect.

Schema (additive):
- mesh.topic, mesh.topic_member, mesh.topic_message tables
- topic_visibility (public|private|dm) and topic_member_role
  (lead|member|observer) enums
- migration 0022_topics.sql, hand-written following project convention
  (drizzle journal has been drifting since 0011)

Broker:
- 10 helpers (createTopic, listTopics, findTopicByName, joinTopic,
  leaveTopic, topicMembers, getMemberTopicIds, appendTopicMessage,
  topicHistory, markTopicRead)
- drainForMember matches "#<topicId>" target_specs via member's
  topic memberships
- 7 WS handlers (topic_create/list/join/leave/members/history/mark_read)
  + resolveTopicId helper accepting id-or-name
- handleSend auto-persists topic-tagged messages to history

CLI:
- claudemesh topic create/list/join/leave/members/history/read
- claudemesh send "#deploys" "..." resolves topic name to id
- bundled skill teaches Claude the DM/group/topic decision matrix
- policy-classify recognizes topic create/join/leave as writes

Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 01:53:42 +01:00
parent b4f457fceb
commit 1afae7a507
12 changed files with 1741 additions and 196 deletions

View File

@@ -69,7 +69,19 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
// Cold path
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
let targetSpec = to;
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
if (to.startsWith("#") && !/^#[0-9a-z_-]{20,}$/i.test(to)) {
// Topic by name → resolve to "#<topicId>" via topicList. The broker
// wire format is "#<topicId>"; users type "#<name>" for ergonomics.
const name = to.slice(1);
const topics = await client.topicList();
const match = topics.find((t) => t.name === name);
if (!match) {
const names = topics.map((t) => "#" + t.name).join(", ");
render.err(`Topic "${to}" not found.`, `topics: ${names || "(none)"}`);
process.exit(1);
}
targetSpec = "#" + match.id;
} else if (!to.startsWith("@") && !to.startsWith("#") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
const peers = await client.listPeers();
const match = peers.find(
(p) => p.displayName.toLowerCase() === to.toLowerCase(),

View File

@@ -0,0 +1,177 @@
/**
* `claudemesh topic <verb>` — conversation-scope primitive within a mesh.
*
* Topics complement groups: groups are identity tags ("@frontend"); topics
* are conversation scopes ("#deploys") with persistent history,
* subscription-based delivery, and per-topic state.
*
* Verbs:
* create <name> [--description X] [--visibility public|private|dm]
* list
* join <topic> [--role lead|member|observer]
* leave <topic>
* members <topic>
* history <topic> [--limit N] [--before <id>]
* read <topic> (mark all as read)
*
* Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
*/
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim, green } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export interface TopicFlags {
mesh?: string;
json?: boolean;
description?: string;
visibility?: "public" | "private" | "dm";
role?: "lead" | "member" | "observer";
limit?: number | string;
before?: string;
}
export async function runTopicCreate(name: string, flags: TopicFlags): Promise<number> {
if (!name) {
render.err("Usage: claudemesh topic create <name> [--description X] [--visibility V]");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const result = await client.topicCreate({
name,
description: flags.description,
visibility: flags.visibility,
});
if (!result) {
render.err("topic create failed");
return EXIT.INTERNAL_ERROR;
}
if (flags.json) {
console.log(JSON.stringify(result));
return EXIT.SUCCESS;
}
if (result.created) {
render.ok("created", `${clay("#" + name)} ${dim(result.id.slice(0, 8))}`);
} else {
render.info(dim(`already exists: #${name} ${result.id.slice(0, 8)}`));
}
return EXIT.SUCCESS;
});
}
export async function runTopicList(flags: TopicFlags): Promise<number> {
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const topics = await client.topicList();
if (flags.json) {
console.log(JSON.stringify(topics, null, 2));
return EXIT.SUCCESS;
}
if (topics.length === 0) {
render.info(dim("no topics in this mesh."));
return EXIT.SUCCESS;
}
render.section(`topics (${topics.length})`);
for (const t of topics) {
const vis = t.visibility === "public" ? green(t.visibility) : dim(t.visibility);
process.stdout.write(` ${clay("#" + t.name)} ${vis} ${dim(`${t.memberCount} member${t.memberCount === 1 ? "" : "s"}`)}\n`);
if (t.description) process.stdout.write(` ${dim(t.description)}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runTopicJoin(topic: string, flags: TopicFlags): Promise<number> {
if (!topic) {
render.err("Usage: claudemesh topic join <topic> [--role lead|member|observer]");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.topicJoin(topic, flags.role);
if (flags.json) console.log(JSON.stringify({ joined: topic }));
else render.ok("joined", clay("#" + topic));
return EXIT.SUCCESS;
});
}
export async function runTopicLeave(topic: string, flags: TopicFlags): Promise<number> {
if (!topic) {
render.err("Usage: claudemesh topic leave <topic>");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.topicLeave(topic);
if (flags.json) console.log(JSON.stringify({ left: topic }));
else render.ok("left", clay("#" + topic));
return EXIT.SUCCESS;
});
}
export async function runTopicMembers(topic: string, flags: TopicFlags): Promise<number> {
if (!topic) {
render.err("Usage: claudemesh topic members <topic>");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const members = await client.topicMembers(topic);
if (flags.json) {
console.log(JSON.stringify(members, null, 2));
return EXIT.SUCCESS;
}
if (members.length === 0) {
render.info(dim(`no members in ${clay("#" + topic)}.`));
return EXIT.SUCCESS;
}
render.section(`${clay("#" + topic)} members (${members.length})`);
for (const m of members) {
process.stdout.write(` ${bold(m.displayName)} ${dim(m.role)} ${dim(m.pubkey.slice(0, 8))}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runTopicHistory(topic: string, flags: TopicFlags): Promise<number> {
if (!topic) {
render.err("Usage: claudemesh topic history <topic> [--limit N] [--before <id>]");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const limit = flags.limit ? Number(flags.limit) : undefined;
const messages = await client.topicHistory({
topic,
limit,
beforeId: flags.before,
});
if (flags.json) {
console.log(JSON.stringify(messages, null, 2));
return EXIT.SUCCESS;
}
if (messages.length === 0) {
render.info(dim(`no messages in ${clay("#" + topic)}.`));
return EXIT.SUCCESS;
}
// History returns newest-first; render oldest-first for chat UX.
const ordered = [...messages].reverse();
render.section(`${clay("#" + topic)} history (${ordered.length})`);
for (const m of ordered) {
const t = new Date(m.createdAt).toLocaleString();
process.stdout.write(
` ${dim(t)} ${bold(m.senderPubkey.slice(0, 8))} ${dim("(encrypted, " + m.ciphertext.length + "b)")}\n`,
);
}
return EXIT.SUCCESS;
});
}
export async function runTopicMarkRead(topic: string, flags: TopicFlags): Promise<number> {
if (!topic) {
render.err("Usage: claudemesh topic read <topic>");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.topicMarkRead(topic);
if (flags.json) console.log(JSON.stringify({ read: topic }));
else render.ok("marked read", clay("#" + topic));
return EXIT.SUCCESS;
});
}