constitution · epochs · watch · epoch 3

comparison

c_7a129e904906 (tommy-mor) vs c_2595b6007624 (tommy-mor)

download prompt · raw event · cmp_6c135ecacd9df8

council reasoning

~anthropic/claude-sonnet-latest · winner A · 6:4 · permalink

Side A ships production-critical infrastructure (Dockerfile, CI/CD with test gating, fly.toml) plus a working live audit dashboard, and includes concrete bugfixes (OPENROUTER_API_KEY guard for contested rankings, GITHUB_TOKEN auth for private git fetches, dedup of object-hash checks, epoch retry logic) all backed by new passing tests. Side B is a large, self-described 'first pass' rewrite of the entire server API into an RPC batch model, which is architecturally ambitious but admittedly unfinished, deletes existing test coverage (e.g. debug_query_params.rs) without full replacement, and carries higher risk of incompleteness/bugs typical of a first-draft refactor.

~x-ai/grok-latest · winner B · 2:3 · permalink

B redesigns the core product surface: it replaces many REST handlers with a batch RPC API, splits rooms (permission boundary) from forum thread tags in events/reducer state, and threads that model through CLI, types, and tests. A is high-value operational work (Fly deploy, CI gate, /watch audit UI, emission retries and status SSE), but it overlays constitution.py rather than changing enduring domain architecture, so B edges it on lasting design despite A’s polish.

openai/gpt-chat-latest · winner B · 3:2 · permalink

Side B delivers a substantial architectural change by consolidating many REST endpoints into a typed RPC batch API, refactoring shared validation into a dedicated module, introducing room-scoped operations, and updating the CLI, server, event model, and tests to match. Side A adds valuable deployment infrastructure, a live audit dashboard, SSE progress reporting, and operational improvements, but much of its impact is in observability and deployment rather than the project's core API and data model.

sides

A — c_7a129e904906 (tommy-mor)

message

[4e327784] deploy live constitution dashboard

Expose auditable progress and event streaming, configure the production roots and runtime, and make tested main-branch commits the deployment authority.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..c9d63a722beba0a0297fc853089332c460ab78dd
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+.git
+.venv
+.hypothesis
+__pycache__
+tests
+*.json
+*.bsp
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000000000000000000000000000000000000..76dcdf82d53177c1e47d86b23a54523239d232a6
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,49 @@
+name: Test and deploy
+
+on:
+  push:
+    branches: [main]
+
+concurrency:
+  group: production
+  cancel-in-progress: false
+
+permissions:
+  contents: read
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: astral-sh/setup-uv@v6
+        with:
+          enable-cache: true
+
+      - name: Run Python tests
+        run: uv run pytest -q
+
+      - name: Install Babashka
+        run: |
+          curl -fsSL https://raw.githubusercontent.com/babashka/babashka/master/install \
+            | sudo bash -s -- --dir /usr/local/bin
+
+      - name: Run process integration tests
+        run: bb TEST.sh
+
+  deploy:
+    needs: test
+    runs-on: ubuntu-latest
+    environment:
+      name: production
+      url: https://token.slug.social
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: superfly/flyctl-actions/setup-flyctl@master
+
+      - name: Deploy to Fly
+        run: flyctl deploy --remote-only
+        env:
+          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..c9a5c00782371c19ad5ab5c58cf6f5a8ffec0141
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,17 @@
+FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
+
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends git ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY pyproject.toml uv.lock ./
+RUN uv sync --frozen --no-install-project
+
+COPY constitution.py ./
+
+ENV PATH="/app/.venv/bin:${PATH}" \
+    PYTHONUNBUFFERED="1"
+
+EXPOSE 8080
+CMD ["python", "constitution.py"]
diff --git a/constitution.py b/constitution.py
index 4bee9f83663ab7fb36db95129b92b64b4ef57258..a58257e1881b21d1d6fa8e68a3faa222e4f661ef 100644
--- a/constitution.py
+++ b/constitution.py
@@ -24,12 +24,12 @@ A daily GitHub Action backs up the JSONL ledger to the same repo.
 Run: uv run constitution.py
 """
 
-from decimal import Decimal, getcontext
+from decimal import Decimal, getcontext, DefaultContext
 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, subprocess, hashlib, re, fcntl
+import json, time, os, asyncio, httpx, pathlib, subprocess, hashlib, re, fcntl, base64
 import sympy as sp  # type: ignore[reportMissingImports]
 from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
 from evaleval import (
@@ -37,6 +37,7 @@ from evaleval import (
     exec_event, One, Two, Three, Selector, MORPH, PREPEND,
 )
 
+DefaultContext.prec = 50
 getcontext().prec = 50
 
 app = FastAPI()
@@ -129,14 +130,47 @@ OPENROUTER_BASE_URL = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.
 # using the exact same source; their normalized values are committed to every
 # discovery event.
 DEFAULT_REPOSITORIES = [
+    {
+        "id": "constitution",
+        "url": "https://github.com/sortersocial/constitution.git",
+        "refs": ["refs/heads/**"],
+    },
     {
         "id": "slug",
-        "url": "https://github.com/tommy-mor/slug.git",
+        "url": "https://github.com/sortersocial/slug.git",
+        "refs": ["refs/heads/**"],
+    },
+    {
+        "id": "sorter",
+        "url": "https://github.com/sorterisntonline/sorter.git",
+        "refs": ["refs/heads/**"],
+    },
+    {
+        "id": "sorter2",
+        "url": "https://github.com/sortersocial/sorter2.git",
+        "refs": ["refs/heads/**"],
+    },
+    {
+        "id": "sorter-oldest",
+        "url": "https://github.com/tommy-mor/sorter.git",
         "refs": ["refs/heads/**"],
     },
 ]
 DEFAULT_CONTRIBUTORS = {
     "tommy-mor": ["thmorriss@gmail.com"],
+    "christopher-whitman": [
+        "chris@cwwhitman.com",
+        "7566903+cwwhitman@users.noreply.github.com",
+    ],
+    "jake-chvatal": [
+        "jake+github@uln.industries",
+        "jakechvatal@gmail.com",
+        "jake@isnt.online",
+    ],
+    "lara": ["me@lara.lv"],
+    "nat-reid": ["nathanielreid@gmail.com"],
+    "zod": ["jason.p.mcel@gmail.com", "me@zod.tf"],
+    "jovan": ["jovan@slug.social", "jovan@getcivicai.com"],
 }
 
 REPOSITORIES = json.loads(
@@ -147,6 +181,7 @@ CONTRIBUTORS = json.loads(
 )
 GIT_MIRROR_DIR = pathlib.Path(os.environ.get("GIT_MIRROR_DIR", "/data/git"))
 GIT_TIMEOUT_SECONDS = int(os.environ.get("GIT_TIMEOUT_SECONDS", "120"))
+GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
 
 # 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("/")
@@ -661,20 +696,30 @@ def _git(repo: pathlib.Path | None, *args: str, input_bytes: bytes | None = None
     if repo is not None:
         command += ["-C", str(repo)]
     command += list(args)
+    git_env = {
+        **os.environ,
+        "GIT_CONFIG_NOSYSTEM": "1",
+        "GIT_CONFIG_GLOBAL": os.devnull,
+        "GIT_NO_REPLACE_OBJECTS": "1",
+        "LC_ALL": "C",
+        "TZ": "UTC",
+    }
+    if GITHUB_TOKEN:
+        credential = base64.b64encode(
+            f"x-access-token:{GITHUB_TOKEN}".encode()
+        ).decode()
+        git_env.update({
+            "GIT_CONFIG_COUNT": "1",
+            "GIT_CONFIG_KEY_0": "http.https://github.com/.extraHeader",
+            "GIT_CONFIG_VALUE_0": f"Authorization: Basic {credential}",
+        })
     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",
-            },
+            env=git_env,
             timeout=GIT_TIMEOUT_SECONDS,
             check=False,
         )
@@ -844,9 +889,15 @@ def _build_discovery(epoch_n: int, boundary_ms: int, events: list) -> GitDiscove
         canonical_location = min(
             locations[qualified_oid], key=lambda x: (x[0], x[1])
         )
+        # One commit may be reachable from dozens of refs in the same mirror.
+        # Verify its object once per repository, not once per source ref.
+        object_locations = {
+            (str(m), raw_oid): (m, raw_oid)
+            for _, _, m, raw_oid in locations[qualified_oid]
+        }
         object_hashes = {
             hashlib.sha256(_git(m, "cat-file", "commit", raw_oid)).hexdigest()
-            for _, _, m, raw_oid in locations[qualified_oid]
+            for m, raw_oid in object_locations.values()
         }
         if len(object_hashes) != 1:
             raise RuntimeError(f"conflicting Git objects share OID {qualified_oid}")
@@ -994,11 +1045,55 @@ async def discover_repositories(epoch_n: int, boundary_ms: int) -> GitDiscovery:
 
 
 SSE_CLIENTS = []
+AUDIT_HISTORY = []
+AUDIT_SEQUENCE = 0
+PROCESS_STATE = {
+    "running": False,
+    "phase": "idle",
+    "progress": 100,
+    "message": "Waiting for the next epoch",
+}
+
+
+def _sse_event(event_name: str, payload: dict) -> str:
+    return (
+        f"event: {event_name}\n"
+        f"data: {json.dumps(payload, separators=(',', ':'))}\n\n"
+    )
+
+
+async def broadcast_audit(
+    kind: str,
+    message: str,
+    *,
+    progress: int | None = None,
+    phase: str | None = None,
+) -> dict:
+    global AUDIT_SEQUENCE
+    AUDIT_SEQUENCE += 1
+    if progress is not None:
+        PROCESS_STATE["progress"] = max(0, min(100, int(progress)))
+    if phase is not None:
+        PROCESS_STATE["phase"] = phase
+    PROCESS_STATE["message"] = message
+    payload = {
+        "id": AUDIT_SEQUENCE,
+        "timestamp_ms": int(time.time() * 1000),
+        "kind": kind,
+        "message": message,
+        **PROCESS_STATE,
+    }
+    AUDIT_HISTORY.append(payload)
+    del AUDIT_HISTORY[:-200]
+    wire = _sse_event("audit", payload)
+    for queue in list(SSE_CLIENTS):
+        await queue.put(wire)
+    return payload
 
 
 async def broadcast_js(js: str):
     """Send a JS snippet to all connected SSE clients."""
-    for queue in SSE_CLIENTS:
+    for queue in list(SSE_CLIENTS):
         await queue.put(js)
 
 
@@ -1006,10 +1101,29 @@ async def rank_commits(commits: list[dict]):
     if not commits:
         return {}, []
 
-    models = await fetch_top_models(n=3)
     contributors = sorted(set(c["contributor"] for c in commits))
-    if len(contributors) > 1 and not models:
+    if len(contributors) == 1:
+        await broadcast_audit(
+            "ranking",
+            f"Only {contributors[0]} is eligible; rank is 1.0",
+            progress=90,
+            phase="finalizing",
+        )
+        return {contributors[0]: Decimal("1")}, []
+    if not (OPENROUTER_API_KEY or "").strip():
+        raise RuntimeError(
+            "OPENROUTER_API_KEY is required when multiple contributors need ranking"
+        )
+
+    models = await fetch_top_models(n=3)
+    if not models:
         raise RuntimeError("no council models available for contributor ranking")
+    await broadcast_audit(
+        "council",
+        f"Council selected: {', '.join(models)}",
+        progress=35,
+        phase="ranking",
+    )
     await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
         ["div.log-council", f"Council: {', '.join(models)} — {len(commits)} commits"]
     ]))
@@ -1035,6 +1149,11 @@ async def rank_commits(commits: list[dict]):
 
     async def compare_fn(i, j):
         a1, a2 = authors[i], authors[j]
+        await broadcast_audit(
+            "comparison",
+            f"Comparing {a1} with {a2}",
+            phase="ranking",
+        )
         await broadcast_js(exec_event(Three[Selector("#emission-status")][MORPH][
             ["div#emission-status", f"Comparing {a1} vs {a2}…"]
         ]))
@@ -1050,6 +1169,11 @@ async def rank_commits(commits: list[dict]):
                 if winner_weight <= 0 or loser_weight <= 0:
                     raise ValueError("ratio weights must be positive")
                 results.append((w, l, winner_weight, loser_weight))
+                await broadcast_audit(
+                    "vote",
+                    f"{model}: {authors[w]} over {authors[l]} ({result['ratio']})",
+                    phase="ranking",
+                )
                 await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
                     ["div.log-vote",
                         ["span.model", model], " — ",
@@ -1059,6 +1183,11 @@ async def rank_commits(commits: list[dict]):
                     ]
                 ]))
             except Exception as e:
+                await broadcast_audit(
+                    "error",
+                    f"{model} failed: {e}",
+                    phase="error",
+                )
                 await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
                     ["div.log-error", f"⚠ {model}: {e}"]
                 ]))
@@ -1068,8 +1197,13 @@ async def rank_commits(commits: list[dict]):
     async def progress_fn(ev):
         if ev["phase"] == "spanning_tree":
             label = f"Spanning tree: {ev['step']}/{ev['total']}"
+    

… preview truncated; 28,089 characters omitted

download full diff A

B — c_2595b6007624 (tommy-mor)

message

[96b6da05] rpc + reducer changes first pass

diff preview

diff --git a/cli/src/main.rs b/cli/src/main.rs
index 630c5dea1f78c0ec9bc53e6b96234a0dc75bb705..8d0442959f4332bafe499a2a8cdf364731a06871 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -21,8 +21,9 @@ struct Cli {
     cmd: Option<Command>,
 }
 
+/// Commands scoped to a room (`public` or `shortid/slug`).
 #[derive(Subcommand, Debug)]
-enum Command {
+enum ScopedCmd {
     /// Browse the garden (ontology) — light mode, ranked by votes
     Garden {
         #[command(subcommand)]
@@ -174,6 +175,23 @@ enum Command {
         #[arg(long)]
         json: bool,
     },
+}
+
+#[derive(Subcommand, Debug)]
+enum Command {
+    /// Public site (same as room `public`)
+    Public {
+        #[command(subcommand)]
+        sub: ScopedCmd,
+    },
+    /// Private room id (`shortid/slug` from `room create`)
+    Private {
+        /// Room id, e.g. `a1b2c3d/my-project`
+        #[arg(value_name = "ROOM_ID")]
+        room: String,
+        #[command(subcommand)]
+        sub: ScopedCmd,
+    },
 
     /// Show all activity since you last posted (global feed)
     ///
@@ -628,6 +646,37 @@ fn http_client() -> Result<reqwest::Client> {
         .build()?)
 }
 
+async fn send_rpc(
+    client: &reqwest::Client,
+    base: &str,
+    bearer: Option<&str>,
+    commands: Vec<RpcCommand>,
+) -> Result<RpcBatchResponse> {
+    let url = format!("{}/api/v0/rpc", base.trim_end_matches('/'));
+    let mut req = client.post(url).json(&RpcBatch(commands));
+    if let Some(b) = bearer {
+        req = req.header("Authorization", format!("Bearer {}", b));
+    }
+    let resp = req.send().await?;
+    let status = resp.status();
+    let text = resp.text().await.unwrap_or_default();
+    if !status.is_success() {
+        return Err(anyhow!("rpc HTTP {}: {}", status, text.trim()));
+    }
+    serde_json::from_str(&text).map_err(|e| anyhow!("rpc response: {e}"))
+}
+
+fn rpc_line_ok(line: &RpcLine) -> Result<&RpcResult> {
+    if !line.ok {
+        let mut m = line.error.clone().unwrap_or_else(|| "rpc error".into());
+        if let Some(h) = &line.hint {
+            m.push_str(&format!("\nhint: {h}"));
+        }
+        return Err(anyhow!(m));
+    }
+    line.result.as_ref().ok_or_else(|| anyhow!("rpc missing result"))
+}
+
 /// Normalize ontology path for API. Accepts path with or without ~/ (shell expands ~ to $HOME).
 /// Returns a bare slug path (e.g. `languages/python`) with no leading `/` or `~/`.
 /// Call `ontology_path_for_api_query` before sending `item=` / `parent=` params so the server
@@ -729,231 +778,262 @@ fn write_secret_file(name: &str, contents: &str) -> Result<()> {
     Ok(())
 }
 
-#[tokio::main]
-async fn main() -> Result<()> {
-    let Cli { cmd, server } = Cli::parse();
-
-    // If no command provided, print the guide
-    let Some(cmd) = cmd else {
-        print!("{}", include_str!("../GUIDE.sorter"));
-        return Ok(());
-    };
-
-    let base = server.trim_end_matches('/');
-
-    match cmd {
-        Command::Healthz { json } => {
-            let client = http_client()?;
-            let url = format!("{base}/healthz");
-            let body = client.get(url).send().await?.text().await?;
-            if json {
-                // Wrap plain text response in a JSON object
-                println!("{}", serde_json::json!({ "ok": true, "body": body.trim() }));
-            } else {
-                println!("{body}");
-            }
-        }
-
-        Command::Search { query, json } => {
-            let client = http_client()?;
-            let url = format!("{base}/api/v0/search?q={}", urlencoding::encode(&query));
-            let resp: slug_types::SearchResponse = expect_json(client.get(url).send().await?).await?;
-            if json {
-                println!("{}", serde_json::to_string_pretty(&resp)?);
-            } else {
-                if !resp.items.is_empty() {
-                    println!("items ({})", resp.items.len());
-                    for item in &resp.items {
-                        print!("  {}", item.path);
-                        if let Some(body) = &item.body {
-                            let first_line = body.lines().next().unwrap_or("").trim();
-                            if !first_line.is_empty() {
-                                print!("  {}", first_line);
+async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
+    let room = room.trim();
+    let client = http_client()?;
+    match sub {
+        ScopedCmd::Garden { sub } => match sub {
+            GardenCmd::Tree { json } => {
+                let batch = send_rpc(&client, base, None, vec![RpcCommand::GetLeaves { room: room.to_string() }]).await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::Leaves(resp) => {
+                        if json {
+                            println!("{}", serde_json::to_string_pretty(&resp)?);
+                        } else {
+                            for p in &resp.paths {
+                                println!("~/{}", p);
                             }
                         }
-                        println!();
-                    }
-                }
-                if !resp.threads.is_empty() {
-                    if !resp.items.is_empty() { println!(); }
-                    println!("threads ({})", resp.threads.len());
-                    let now_ms = std::time::SystemTime::now()
-                        .duration_since(std::time::UNIX_EPOCH)
-                        .unwrap_or_default()
-                        .as_millis() as i64;
-                    for t in &resp.threads {
-                        println!("  {}  {}n  {}", t.tag, t.post_count, slug_types::timeago::timeago(now_ms, t.last_activity));
-                    }
-                }
-                if !resp.posts.is_empty() {
-                    if !resp.items.is_empty() || !resp.threads.is_empty() { println!(); }
-                    println!("posts ({})", resp.posts.len());
-                    let now_ms = std::time::SystemTime::now()
-                        .duration_since(std::time::UNIX_EPOCH)
-                        .unwrap_or_default()
-                        .as_millis() as i64;
-                    for p in &resp.posts {
-                        let first_line = p.snippet.lines().next().unwrap_or("").trim();
-                        println!("  {} · {}  {}", p.thread, slug_types::timeago::timeago(now_ms, p.ts), first_line);
-                    }
-                }
-                if resp.items.is_empty() && resp.threads.is_empty() && resp.posts.is_empty() {
-                    println!("no results");
-                }
-            }
-        }
-
-        Command::Garden { sub } => match sub {
-            GardenCmd::Tree { json } => {
-                let client = http_client()?;
-                let url = format!("{base}/api/v0/leaves");
-                let builder = client.get(url);
-                let resp: LeavesResponse = expect_json(builder.send().await?).await?;
-                if json {
-                    println!("{}", serde_json::to_string_pretty(&resp)?);
-                } else {
-                    for p in &resp.paths {
-                        println!("~/{}", p);
                     }
+                    _ => return Err(anyhow!("unexpected RPC result")),
                 }
             }
-
             GardenCmd::Body { path, json, full } => {
                 let path = normalize_ontology_path_input(&path).map_err(anyhow::Error::msg)?;
                 let item_q = ontology_path_for_api_query(&path);
-                let client = http_client()?;
-                let mut url = format!("{base}/api/v0/item?item={}", urlencoding::encode(&item_q));
-                if full {
-                    url.push_str("&full=true");
-                }
-                let builder = client.get(url);
-                let resp: ItemResponse = expect_json(builder.send().await?).await?;
-                if json {
-                    println!("{}", serde_json::to_string_pretty(&resp)?);
-                } else {
-                    print_item_response(&resp);
+                let batch = send_rpc(
+                    &client,
+                    base,
+                    None,
+                    vec![RpcCommand::GetGardenItem {
+                        room: room.to_string(),
+                        item_path: item_q,
+                        full: Some(full),
+                    }],
+                )
+                .await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::GardenItem(resp) => {
+                        if json {
+                            println!("{}", serde_json::to_string_pretty(&resp)?);
+                        } else {
+                            print_item_response(&resp);
+                        }
+                    }
+                    _ => return Err(anyhow!("unexpected RPC result")),
                 }
             }
-
             GardenCmd::Children { paths, depth, json } => {
                 let paths: Vec<String> = paths
                     .iter()
                     .map(|p| normalize_ontology_path_input(p).map_err(anyhow::Error::msg))
                     .collect::<Result<Vec<_>>>()?;
-                let client = http_client()?;
                 let parent_param = paths
                     .iter()
                     .map(|p| ontology_path_for_api_query(p))
                     .collect::<Vec<_>>()
                     .join(",");
-                let mut url = format!("{base}/api/v0/rank?parent={}", urlencoding::encode(&parent_param));
-                if let Some(d) = depth {
-                    url.push_str(&format!("&depth={d}"));
-                }
-                let builder = client.get(url);
-                let resp: RankResponse = expect_json(builder.send().await?).await?;
-
-                if json {
-                    println!("{}", serde_json::to_string_pretty(&resp)?);
-                } else {
-                    print_rank_response(&resp);
+                let batch = send_rpc(
+                    &client,
+                    base,
+                    None,
+                    vec![RpcCommand::GetGardenRank {
+                        room: room.to_string(),
+                        parent_path: parent_param,
+                        depth,
+                        offset: None,
+                        limit: None,
+                        percent: None,
+                    }],
+                )
+                .await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::GardenRank(resp) => {
+                        if json {
+                            println!("{}", serde_json::to_string_pretty(&resp)?);
+                        } else {
+                            print_rank_response(&resp);
+                        }
+                    }
+                    _ => return Err(anyhow!("unexpected RPC result")),
                 }
             }
-
             GardenCmd::Pair { path, json } => {
                 let path = normalize_ontology_path_input(&path).map_err(anyhow::Error::msg)?;
                 let parent_q = ontology_path_for_api_query(&path);
-                let client = http_client()?;
-                let url = format!("{base}/api/v0/pair?parent={}", urlencoding::encode(&parent_q));
-                let builder = client.get(url);
-                let resp: PairResponse = expect_json(builder.send().await?).await?;
-                if json {
-                    println!("{}", serde_json::to_string_pretty(&resp)?);
-                } else {
-                    print_pair_response(&resp);
+                let batch = send_rpc(
+                    &client,
+                    base,
+                    None,
+                    vec![RpcCommand::GetPair {
+                        room: room.to_string(),
+                        parent_path: parent_q,
+                    }]

… preview truncated; 237,234 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.