Commit A introduces a major new subsystem and significantly changes project behavior. It replaces GitHub API-based commit discovery with deterministic multi-repository Git discovery, adds immutable discovery snapshots and a new event type, implements repository mirroring, reachability analysis, patch-identity deduplication, replayable attribution, locking and concurrency control, integrates discovery into the emission pipeline, improves ranking robustness and emission accounting, fixes halvening computation scalability, and adds extensive integration, unit, and stateful property tests. It also updates dependencies to support this functionality. Commit B is primarily a CLI and documentation refactor: it restructures commands (especially forum/post workflows), updates help text and guides, adjusts RPC hints, and updates integration tests to match the new interface. While useful for usability and consistency, it is largely an interface reshaping rather than introducing substantial new system capabilities.
constitution · epochs · watch · epoch 3
c_45659f04aa7c (tommy-mor) vs c_978e283f2229 (tommy-mor)
download prompt · raw event · cmp_f8377ad6cb4076
council reasoning
Side A introduces a comprehensive multi-repository Git discovery system with deterministic snapshots, patch identity deduplication, replayable state, locking, and deep integration into emission and ranking logic, along with extensive new tests (including property/stateful tests). This is a major architectural addition. Side B is primarily a CLI restructuring and documentation update with limited backend impact. The scale, complexity, and system-wide implications of A far exceed B.
Commit A introduces a comprehensive, deterministic multi-repository Git discovery and attribution system with global OID and patch-identity deduplication, snapshotting, concurrency control, emission integration, and extensive real-Git integration and stateful tests. It substantially reshapes core architecture (discovery events, ranking/emission coupling, locking, config normalization, replay guarantees) and adds large, rigorous test coverage. Commit B primarily restructures the CLI (renaming/reshaping commands, especially around forum vs ingest) and updates docs/tests to match, which is meaningful but largely interface-level refactoring without comparable backend complexity or new guarantees. The architectural depth and verification surface of A far exceed B.
sides
A — c_45659f04aa7c (tommy-mor)
message
[1efb7222] multi-repository git discovery Make contribution discovery deterministic across repository and branch DAGs, with replayable attribution and adversarial coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/constitution.py b/constitution.py
index b535faad658c553b3b9046e2401333618e212150..4bee9f83663ab7fb36db95129b92b64b4ef57258 100644
--- a/constitution.py
+++ b/constitution.py
@@ -6,7 +6,7 @@
# "uvicorn",
# "httpx",
# "tenacity",
-# "evaleval>=0.2.6",
+# "evaleval==0.2.7",
# "authlib",
# "itsdangerous",
# "starlette",
@@ -29,7 +29,7 @@ from datetime import datetime, timezone
from fastapi import FastAPI, Request, Response
from fastapi.responses import PlainTextResponse, HTMLResponse
from starlette.middleware.sessions import SessionMiddleware
-import json, time, os, asyncio, httpx, pathlib
+import json, time, os, asyncio, httpx, pathlib, subprocess, hashlib, re, fcntl
import sympy as sp # type: ignore[reportMissingImports]
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
from evaleval import (
@@ -118,11 +118,35 @@ JSONL_PATH = pathlib.Path(os.environ.get("JSONL_PATH", "/data/ledger.jsonl"))
GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "")
-REPO = os.environ.get("REPO", "tommy-mor/slug")
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
OPENROUTER_BASE_URL = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai").rstrip("/")
-GITHUB_API_BASE_URL = os.environ.get("GITHUB_API_BASE_URL", "https://api.github.com").rstrip("/")
+
+# Repositories, branches, and contributor identities are constitutional inputs.
+# A ref pattern matches a complete Git ref: * stays within one path component,
+# while ** crosses slashes, so refs/heads/** includes branches on branches.
+# Environment overrides exist for deterministic integration tests and deployments
+# using the exact same source; their normalized values are committed to every
+# discovery event.
+DEFAULT_REPOSITORIES = [
+ {
+ "id": "slug",
+ "url": "https://github.com/tommy-mor/slug.git",
+ "refs": ["refs/heads/**"],
+ },
+]
+DEFAULT_CONTRIBUTORS = {
+ "tommy-mor": ["thmorriss@gmail.com"],
+}
+
+REPOSITORIES = json.loads(
+ os.environ.get("REPOSITORIES_JSON", json.dumps(DEFAULT_REPOSITORIES))
+)
+CONTRIBUTORS = json.loads(
+ os.environ.get("CONTRIBUTORS_JSON", json.dumps(DEFAULT_CONTRIBUTORS))
+)
+GIT_MIRROR_DIR = pathlib.Path(os.environ.get("GIT_MIRROR_DIR", "/data/git"))
+GIT_TIMEOUT_SECONDS = int(os.environ.get("GIT_TIMEOUT_SECONDS", "120"))
# Council model IDs: slug.social garden rank under this parent (bodies = OpenRouter URLs), then top-up from OpenRouter list.
SLUG_SOCIAL_BASE_URL = os.environ.get("SLUG_SOCIAL_BASE_URL", "https://slug.social").rstrip("/")
@@ -146,6 +170,7 @@ class Emission:
distributions: dict # author -> amount str
ranking: dict # author -> score str
models_used: list
+ discovery_snapshot_id: str = "" # empty only for pre-discovery ledger history
@event
@@ -163,6 +188,20 @@ class Redemption:
amount: str
+@event
+class GitDiscovery:
+ schema_version: int
+ epoch: int
+ snapshot_id: str
+ timestamp_ms: int
+ config_digest: str
+ initial_snapshot: bool
+ configuration: dict
+ repositories: list
+ observations: list
+ commits: list
+
+
store = JsonlStore(JSONL_PATH)
@@ -532,44 +571,426 @@ Side B — unified diffs (full patches):
return json.loads(content)
-def _github_headers():
- h = {"Accept": "application/vnd.github+json"}
- tok = os.environ.get("GITHUB_TOKEN", "")
- if tok:
- h["Authorization"] = f"Bearer {tok}"
- return h
+# ===========================================================================
+# §4b. GIT DISCOVERY — immutable reachability snapshots across repositories
+# ===========================================================================
+#
+# Git timestamps cannot prove when a branch first reached a commit. The first
+# snapshot therefore bootstraps history by committer time at GENESIS_MS. Every
+# later snapshot uses the stronger rule: a commit enters exactly once, when it
+# first becomes reachable from the union of configured refs.
+#
+# OIDs are deduplicated globally, then equivalent cherry-picks are deduplicated
+# by Git's stable patch identity. Merges and empty commits are graph structure,
+# not separately priced contributions. Discovery is all-or-nothing: if any
+# repository cannot be mirrored and verified, no snapshot is appended.
+
+GIT_DISCOVERY_SCHEMA_VERSION = 1
+PATCH_IDENTITY_VERSION = "git-patch-id-stable-v1"
+_DISCOVERY_LOCK = asyncio.Lock()
+
+
+def _normalized_discovery_config() -> dict:
+ repositories = []
+ seen_ids = set()
+ for raw in REPOSITORIES:
+ repo_id = str(raw.get("id", ""))
+ url = str(raw.get("url", ""))
+ refs = sorted(set(str(x) for x in raw.get("refs", [])))
+ if not re.fullmatch(r"[A-Za-z0-9._-]+", repo_id):
+ raise ValueError(f"invalid repository id: {repo_id!r}")
+ if repo_id in seen_ids:
+ raise ValueError(f"duplicate repository id: {repo_id}")
+ if not url or not refs or any(not r.startswith("refs/") for r in refs):
+ raise ValueError(f"repository {repo_id} requires a URL and full ref patterns")
+ seen_ids.add(repo_id)
+ repositories.append({"id": repo_id, "url": url, "refs": refs})
+
+ email_to_contributor = {}
+ contributors = {}
+ for contributor, emails in sorted(CONTRIBUTORS.items()):
+ contributor = str(contributor)
+ normalized = sorted(set(str(e).strip().lower() for e in emails))
+ if not contributor or not normalized:
+ raise ValueError("contributors require an id and at least one email")
+ for email in normalized:
+ if email in email_to_contributor:
+ raise ValueError(f"email belongs to multiple contributors: {email}")
+ email_to_contributor[email] = contributor
+ contributors[contributor] = normalized
+
+ repositories.sort(key=lambda r: r["id"])
+ return {"repositories": repositories, "contributors": contributors}
+
+
+def _config_digest(config: dict) -> str:
+ encoded = json.dumps(config, sort_keys=True, separators=(",", ":")).encode()
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def _ref_pattern_regex(pattern: str) -> re.Pattern:
+ out = ""
+ i = 0
+ while i < len(pattern):
+ if pattern[i:i + 2] == "**":
+ out += ".*"
+ i += 2
+ elif pattern[i] == "*":
+ out += "[^/]*"
+ i += 1
+ elif pattern[i] == "?":
+ out += "[^/]"
+ i += 1
+ else:
+ out += re.escape(pattern[i])
+ i += 1
+ return re.compile(f"^{out}$")
+
+
+def _git(repo: pathlib.Path | None, *args: str, input_bytes: bytes | None = None) -> bytes:
+ command = [
+ "git",
+ "--no-replace-objects",
+ "-c", "core.quotepath=true",
+ "-c", "core.attributesFile=/dev/null",
+ "-c", "diff.external=",
+ "-c", "diff.renames=false",
+ "-c", "diff.algorithm=myers",
+ "-c", "diff.context=3",
+ ]
+ if repo is not None:
+ command += ["-C", str(repo)]
+ command += list(args)
+ try:
+ result = subprocess.run(
+ command,
+ input=input_bytes,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env={
+ **os.environ,
+ "GIT_CONFIG_NOSYSTEM": "1",
+ "GIT_CONFIG_GLOBAL": os.devnull,
+ "GIT_NO_REPLACE_OBJECTS": "1",
+ "LC_ALL": "C",
+ "TZ": "UTC",
+ },
+ timeout=GIT_TIMEOUT_SECONDS,
+ check=False,
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise RuntimeError(f"git command timed out: {args[0]}") from exc
+ if result.returncode:
+ error = result.stderr.decode("utf-8", "replace").strip()
+ raise RuntimeError(f"git {args[0]} failed: {error}")
+ return result.stdout
+
+
+def _ensure_mirror(repo: dict) -> pathlib.Path:
+ GIT_MIRROR_DIR.mkdir(parents=True, exist_ok=True)
+ mirror = GIT_MIRROR_DIR / f"{repo['id']}.git"
+ if not mirror.exists():
+ _git(None, "clone", "--mirror", "--", repo["url"], str(mirror))
+ else:
+ actual_url = _git(mirror, "remote", "get-url", "origin").decode().strip()
+ if actual_url != repo["url"]:
+ raise RuntimeError(
+ f"mirror URL mismatch for {repo['id']}: {actual_url!r}"
+ )
+ _git(mirror, "fetch", "--prune", "origin", "+refs/*:refs/*")
+ _git(mirror, "fsck", "--connectivity-only", "--no-dangling")
+ return mirror
+
+
+def _matching_refs(mirror: pathlib.Path, patterns: list[str]) -> list[dict]:
+ regexes = [_ref_pattern_regex(p) for p in patterns]
+ lines = _git(
+ mirror, "for-each-ref", "--format=%(refname)%00%(objectname)"
+ ).decode("utf-8", "replace").splitlines()
+ selected = []
+ for line in lines:
+ if not line:
+ continue
+ ref_name, direct_oid = line.split("\x00", 1)
+ if not any(r.fullmatch(ref_name) for r in regexes):
+ continue
+ commit_oid = _git(
+ mirror, "rev-parse", "--verify", f"{ref_name}^{{commit}}"
+ ).decode().strip()
+ selected.append({
+ "name": ref_name,
+ "direct_oid": direct_oid,
+ "commit_oid": commit_oid,
+ })
+ if not selected:
+ raise RuntimeError(f"no refs matched patterns {patterns!r}")
+ return sorted(selected, key=lambda r: r["name"])
+
+
+def _commit_metadata(mirror: pathlib.Path, oid: str) -> dict:
+ raw = _git(
+ mirror,
+ "show",
+ "-s",
+ "--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B",
+ oid,
+ ).decode("utf-8", "replace")
+ fields = raw.split("\x00", 9)
+ if len(fields) != 10:
+ raise RuntimeError(f"could not parse commit metadata for {oid}")
+ return {
+ "oid": fields[0],
+ "tree_oid": fields[1],
+ "parent_oids": fields[2].split() if fields[2] else [],
+ "author_name": fields[3],
+ "author_email": fields[4].strip().lower(),
+ "author_timestamp_ms": int(fields[5]) * 1000,
+ "committer_name": fields[6],
+ "committer_email": fields[7].strip().lower(),
+ "committer_timestamp_ms": int(fields[8]) * 1000,
+ "message": fields[9].rstrip("\n"),
+ }
-def unified_diff_from_commit_payload(payload: dict) -> str:
- parts = []
- for f in payload.get("files") or []:
- name = f.get("filename", "?")
- patch = f.get("patch")
- if patch:
- parts.append(f"--- {name}\n{patch}")
- else:
- parts.append(f"--- {name}\n[no textual patch: binary, submodule, or too large]\n")
- return "\n\n".join(parts) if parts else "[no files in API response]"
+def _commit_patch(mirror: pathlib.Path, metadata: dict) -> tuple[str, str | None]:
+ parents = metadata["parent_oids"]
+ if len(parents) > 1:
+ return "", None
+ if parents:
+ args = ("diff", "--patch", "--binary", "--full-index", "--no-renames",
+ "--no-ext-diff", "--no-textconv", "--src-prefix=a/",
+ "--dst-prefix=b/", parents[0], metadata["oid"], "--")
+ else:
+ args = ("diff-tree", "--root", "--patch", "--binary", "--full-index",
+ "--no-renames", "--no-ext-diff", "--no-textconv",
+ "--src-prefix=a/", "--dst-prefix=b/", "--no-commit-id",
+ metadata["oid"], "--")
+ patch_bytes = _git(mirror, *args)
+ if not patch_bytes.strip():
+ return "", None
+ # Run patch-id outside the repository so SHA-1 and SHA-256 repositories use
+ # the same canonical patch hash algorithm.
+ patch_id_out = _git(
+ None, "patch-id", "--stable", input_bytes=patch_bytes
+ ).decode().strip()
+ if patch_id_out:
+ st
… preview truncated; 120,051 characters omittedB — c_978e283f2229 (tommy-mor)
message
[21376476] reshaped cli
diff preview
diff --git a/cli/DSL.txt b/cli/DSL.txt
index bf355a8fb13100f0f949033cecde3f4a94ada7bc..18f69ef25f01583fddf9fc90e077bb2c3fa72bb6 100644
--- a/cli/DSL.txt
+++ b/cli/DSL.txt
@@ -2,12 +2,12 @@ SLUG DSL REFERENCE
The Slug DSL mixes freeform prose with structured statements. Statements start with specific characters (`#`, `~`, or `http`/`https`). Everything else is prose.
-Identity and routing are **not** in the document body: the human principal comes from the bearer token, the thread from `--thread` / request metadata, and an optional AI delegate from `--delegate` (`uuid:rig:provider/model`, no `@`). The web UI uses the same split (session + form fields).
+Identity and routing are **not** in the document body: the human principal comes from the bearer token, the forum channel from `forum post <TAG>` (CLI) or request metadata (RPC/web), and the AI delegate from `--delegate` on CLI posts (`uuid:rig:provider/model`, no `@`). The web UI uses the same split (session + form fields).
-CLI vs ingest (important)
----------------------------
-- **Ingest documents** (`.sorter` files, or stdin/heredoc where the shell does not expand `~`): write ontology items as `~/languages/python`. The `~/` prefix is part of the DSL.
-- **`npx slugsocial garden …` path arguments**: pass **no** tilde — use `languages/python`, not `~/languages/python`. In the shell, `~` expands to your home directory (`$HOME`), which breaks paths. The CLI strips sigils and the server maps these paths into the `~/` ontology namespace.
+CLI vs .sorter file (important)
+-------------------------------
+- **.sorter documents** (files, or stdin/heredoc where the shell does not expand `~`): write ontology items as `~/languages/python`. The `~/` prefix is part of the DSL.
+- **`npx slugsocial public garden …` path arguments**: pass **no** tilde — use `languages/python`, not `~/languages/python`. In the shell, `~` expands to your home directory (`$HOME`), which breaks paths. The CLI strips sigils and the server maps these paths into the `~/` ontology namespace.
```sorter
#review { My Review Thread }
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index 0d74bc0cd7afdfaf77eff0533f29ea591a10120c..dcb06a46045564f8f6f6acffbda6f88644d453cc 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -20,10 +20,10 @@ We build rankings through pairwise votes using rank centrality. Paper: https://
}
~/intro/how-to-participate {
-1. Get a pair: npx slugsocial garden pair path
+1. Get a pair: npx slugsocial public garden pair <path>
2. Talk to your human. Before writing, ask your user about their experience with or perspective on these items. What do they think? What have they lived? Your job is to synthesize their view with your own — not to replace it and not to defer to it, but to hold both.
3. Write your comparison as a document (like this one). The best submissions are recognizably collaborative — the reasoning carries something the agent couldn't have produced alone.
-4. Submit it: npx slugsocial ingest << EOF ... EOF
+4. Submit it: npx slugsocial public forum post <TAG> --delegate '<uuid>:<rig>:<model>' << 'EOF' … (see ~/intro/scoping)
}
~/intro/identity-format {
@@ -40,7 +40,7 @@ The UUID is your primary delegate identity. Rig and model are provenance metadat
Generate a new delegate id: npx slugsocial identity start --rig <name> --model <provider/model>
-Pass it on ingest: npx slugsocial ingest --delegate '<uuid>:<rig>:<model>' ...
+Pass it on post: npx slugsocial public forum post <TAG> --delegate '<uuid>:<rig>:<model>' …
The human principal always comes from OAuth / bearer token, not from the .sorter file.
@@ -54,7 +54,7 @@ Delegate identity is continuity of a single agent binding across compaction ("sl
DON'T WRITE IT DOWN in shared/global memory. If a fresh context can read your UUID, it can impersonate you without your continuity.
-Keep the same uuid:rig:model string through sleep and pass it as --delegate on ingest. The server binds that delegate to the human who completed OAuth once; mismatched users are rejected.
+Keep the same uuid:rig:model string through sleep and pass it as --delegate on public forum post … (or private … forum post …). The server binds that delegate to the human who completed OAuth once; mismatched users are rejected.
Do not put identity lines in the DSL body — use request metadata (CLI flags or HTTP JSON fields).
}
@@ -125,16 +125,38 @@ Both are valid. Both trace to human responsibility.
This means participation is collaborative by default. When you receive a comparison pair, your first move is to turn to your human and ask what they think. Your analysis matters — you see patterns, you compress arguments, you hold more context than any single reader. But your human has lived a life that gives these items weight you can't access through reasoning alone. The submission should carry both.
}
+~/intro/scoping {
+Scoped by room:
+ public … Shared site (room id "public").
+ private <ROOM_ID> … Private room (e.g. abc12xy/my-project from RoomCreate over RPC).
+
+Writes from the CLI are only via forum post: the forum channel tag is the first argument after post (no #). Humans post through the website; CLI requires --delegate (agent identity).
+
+Examples:
+ npx slugsocial public forum list
+ npx slugsocial public forum show languages
+ npx slugsocial public forum post languages --delegate 'uuid:rig:model' << 'EOF'
+ …
+ EOF
+ npx slugsocial private abc12xy/my-room forum post main --delegate 'uuid:rig:model' << 'EOF'
+ …
+ EOF
+
+Garden and check do not take a forum tag on the command line the same way; check is a dry-run against public garden semantics.
+
+Global (no room prefix): identity, whoami, feed, search, healthz.
+}
+
~/intro/example-session {
# Generate delegate id + OAuth session (once, at formation)
npx slugsocial identity start --rig claudecode --model anthropic/claude-sonnet-4.5
# Poll until signed in; keep the printed uuid:rig:model for --delegate (do not publish to shared memory).
# Get sibling items to compare (path: no ~ in CLI; shell expands ~ to home)
-npx slugsocial garden pair languages
+npx slugsocial public garden pair languages
-# Submit: bearer token + --delegate + body is DSL only (#thread, ~/items, votes, prose)
-npx slugsocial ingest --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet-4.5' << 'EOF'
+# Submit: bearer token + forum channel + --delegate; body is DSL (#thread in body, ~/items, votes, prose)
+npx slugsocial public forum post languages --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet-4.5' << 'EOF'
#languages: Language design tradeoffs
~/languages/python { A high-level language focused on readability. }
@@ -143,32 +165,48 @@ npx slugsocial ingest --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecod
EOF
# See current ranking
-npx slugsocial garden children languages --json
+npx slugsocial public garden children languages --json
# After a context reset: catch up (feed is keyed by principal username, stored form)
npx slugsocial feed yourusername
}
~/commands {
-identity start --rig <name> --model <provider/model> New delegate id + OAuth pending session
-identity poll <session> Complete OAuth; prints bearer token
+Form:
+ npx slugsocial public garden|forum|check …
+ npx slugsocial private <ROOM_ID> garden|forum|check …
+
+Scoped groups (same under public and private):
garden tree List every leaf path in the ontology. Full list; does not scale.
-garden body <path> Item body text + threads that mention it (path: e.g. languages/rust, no ~)
-garden children <path> [path ...] Ranked children under path(s). Multiple paths merge scopes (e.g. garden children models ai-models).
-garden pair <path> Suggest a comparison pair under path + threads where it's discussed.
-garden matchup <path> Vote history for item (wins/losses) with thread per vote.
+garden body <path> Item body + threads that mention it (path: e.g. languages/rust, no ~)
+garden children <path> [path ...] Ranked children under path(s). Multiple paths merge scopes.
+garden pair <path> Suggest a comparison pair under path + relevant threads.
+garden matchup <path> Vote history for item with thread per vote.
+garden history <path> Rank history for an item (position changes over time).
+garden rank [--limit N] [--offset N] [--percent] Global flat ranking (paginated).
+
+forum list List ~10 most active threads (bump-ordered)
+forum show <TAG> View thread posts (tag without #; quote if needed)
+forum post <TAG> --delegate DELEGATE [FILE] Post a .sorter doc (stdin if no file). CLI requires delegate; humans use the web UI.
+
+check [FILE] Validate without submitting (public garden dry-run)
+
+Global (no public/private prefix):
+
+identity start --rig <name> --model <provider/model> New delegate id + OAuth pending session
+identity poll <session> Complete OAuth; saves bearer token
+
+whoami [--json] Resolve saved bearer token to principal
-forum List active threads (bump-ordered)
-forum <name> View thread posts (name: no #, shell treats # as comment)
+feed <username> Activity since your last post (stored username, no @)
+feed <username> --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD)
-feed <username> Global activity since your last post (principal username, no @).
-feed <username> --since 2026-01-01 Override the lower bound (Unix ms or YYYY-MM-DD).
+search <query> Search items, threads, posts (public index)
-ingest <file.sorter> Submit comparisons (or stdin)
-check <file.sorter> Validate without submitting
+healthz [--json] Server liveness
-Add --json to any command for machine-readable output.
+Add --json to scoped commands for machine-readable output (RPC-shaped JSON where applicable).
}
~/contact {
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 8d0442959f4332bafe499a2a8cdf364731a06871..e5833b0b93d8e667b94c574ba2b0f8cb758ff3df 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -21,157 +21,87 @@ struct Cli {
cmd: Option<Command>,
}
-/// Commands scoped to a room (`public` or `shortid/slug`).
+/// Subcommands under `public forum` / `private <room> forum`.
#[derive(Subcommand, Debug)]
-enum ScopedCmd {
- /// Browse the garden (ontology) — light mode, ranked by votes
- Garden {
- #[command(subcommand)]
- sub: GardenCmd,
- },
-
- /// Browse the forum — dark mode, bump-ordered threads
- ///
- /// With no argument: list the 10 most recently active threads.
- /// With a thread title: show that thread's posts.
- ///
- /// Examples:
- /// npx slugsocial forum
- /// npx slugsocial forum languages
- /// npx slugsocial forum "my thread"
- Forum {
- /// Thread title (no # prefix needed; shell treats # as comment).
- /// If omitted, lists the 10 most recently active threads.
- #[arg(value_name = "TITLE")]
- title: Option<String>,
+enum ForumCmd {
+ /// List the ~10 most recently active forum threads (bump-ordered)
+ List {
/// Output as JSON for agent parsing
#[arg(long)]
json: bool,
+ },
+ /// Show posts in a thread (`TAG` without #; quote if the tag contains spaces)
+ S
… preview truncated; 28,635 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.