constitution · epochs · watch · epoch 3

comparison

c_4a5c84c0a37b (tommy-mor) vs c_1c1c8e7a2de8 (tommy-mor)

download prompt · raw event · cmp_a3bfd37f2589ca

council reasoning

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

Side B adds a full CLI room-creation path (new CLI subcommand, RPC wiring, docs) plus a real simplification removing the unused ThreadVisibility concept and switching rooms from HashMap to HashSet, touching many files consistently. Side A is a smaller, focused fix (display_path in hrefs) with a much improved test, which is valuable but narrower in scope than B's cross-cutting feature+cleanup.

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

B adds a real user-facing CLI path (`room create`) and permanently simplifies the domain by dropping `ThreadVisibility`/`RoomState` so rooms are a `HashSet` of private ids, with matching RPC/reducer/docs/tests. A’s lasting substance is smaller: encode `display_path` in vote hrefs plus a stronger 45-pair ranking browser assertion, valuable but incremental versus B’s feature + model cleanup.

openai/gpt-chat-latest · winner A · 4:1 · permalink

Side A fixes user-facing URL generation by using `display_path()` for vote and pool links instead of stored canonical URLs, keeping hrefs consistent with the UI and DSL, and strengthens coverage with an end-to-end test that exercises all 45 pairwise votes and verifies the final garden ranking through `GetGardenRank`. Side B adds a useful `room create` CLI command and simplifies room state by removing unused visibility, but much of the patch is API cleanup, documentation updates, and plumbing rather than a targeted correctness improvement backed by stronger behavioral testing.

sides

A — c_4a5c84c0a37b (tommy-mor)

message

[0728c06a] Vote pool: use display_path in hrefs; test all 45 pairs + assert ranking.

- vote_compare_href and vote_pool_href now encode ~/… and -/… as their
  short display forms (not the full https://slug.social/… storage URL),
  matching what users see in the item display and DSL.
- Rewrite browser_vote_pool test to vote all C(10,2)=45 pairs in the
  pool, always preferring the alphabetically-earlier letter, then query
  GetGardenRank and assert the 10 items form one component ranked a→j.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff preview

diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs
index 2682cfcbd2f834b56459a02831aa225cffe67c58..d0ec78cd675eae284d056fb3b8eaf5cc853d6263 100644
--- a/server/src/html/garden/vote.rs
+++ b/server/src/html/garden/vote.rs
@@ -234,8 +234,10 @@ pub(super) fn vote_compare_href(
     thread_override: Option<&str>,
     pool: Option<&ItemId>,
 ) -> String {
-    let left_q = urlencoding::encode(left.as_str());
-    let right_q = urlencoding::encode(right.as_str());
+    let left_dp = left.display_path();
+    let right_dp = right.display_path();
+    let left_q = urlencoding::encode(&left_dp);
+    let right_q = urlencoding::encode(&right_dp);
     let mut base = format!(
         "{}/vote?left={}&right={}",
         nav.room_path_prefix_for_vote_compare(),
@@ -246,16 +248,20 @@ pub(super) fn vote_compare_href(
         base = format!("{}&thread={}", base, urlencoding::encode(t));
     }
     if let Some(p) = pool {
-        base = format!("{}&pool={}", base, urlencoding::encode(p.as_str()));
+        let pool_dp = p.display_path();
+        base = format!("{}&pool={}", base, urlencoding::encode(&pool_dp));
     }
     base
 }
 
 pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {
+    let display = ItemId::parse(pool_item_str)
+        .map(|i| i.display_path())
+        .unwrap_or_else(|| pool_item_str.to_string());
     format!(
         "{}/vote?pool={}",
         nav.room_path_prefix_for_vote_compare(),
-        urlencoding::encode(pool_item_str)
+        urlencoding::encode(&display)
     )
 }
 
diff --git a/test/browser_vote_pool.clj b/test/browser_vote_pool.clj
index d51f05db47dc3cb013e56e65f3a1edfbbcbd96ab..23d0bd80b02bd8b1b48853454bed02793296550e 100644
--- a/test/browser_vote_pool.clj
+++ b/test/browser_vote_pool.clj
@@ -1,6 +1,8 @@
 (ns test.browser-vote-pool
-  "Pool-scoped voting: seed ~/pool/a-j, enter via /vote?pool=~/pool, follow
-   the vote → next-pair → vote sequence until no next pair or 15 iterations."
+  "Pool-scoped voting: seed ~/pool/a-j (10 letters), follow the
+   vote → next-pair sequence for all C(10,2)=45 pairs voting the
+   alphabetically-earlier item each time, then assert the garden
+   ranking is a…j in order."
   (:require [babashka.fs :as fs]
             [cheshire.core :as json]
             [clojure.string :as str]
@@ -11,26 +13,38 @@
             [test.common :as common]
             [test.oauth :as oauth]))
 
+(def letters ["a" "b" "c" "d" "e" "f" "g" "h" "i" "j"])
+(def total-pairs (/ (* (count letters) (dec (count letters))) 2)) ; C(10,2) = 45
+
 (defn- wait-for-text [pg selector expected timeout-ms]
   (let [deadline (+ (System/currentTimeMillis) timeout-ms)]
     (loop []
-      (let [text (locator/text-content (page/locator pg selector))]
+      (let [text (try (locator/text-content (page/locator pg selector)) (catch Exception _ nil))]
         (if (and (string? text) (str/includes? text expected))
           true
           (if (< (System/currentTimeMillis) deadline)
-            (do (Thread/sleep 200) (recur))
+            (do (Thread/sleep 150) (recur))
             false))))))
 
 (defn- element-text [pg selector]
-  (try (locator/text-content (page/locator pg selector)) (catch Exception _ nil)))
+  (try (locator/text-content (page/locator pg selector)) (catch Exception _ "")))
 
 (defn- enc [^String s]
   (java.net.URLEncoder/encode s "UTF-8"))
 
-(def letters ["a" "b" "c" "d" "e" "f" "g" "h" "i" "j"])
+;; Extract the terminal path segment, e.g. "~/pool/c" → "c".
+(defn- leaf [path] (last (str/split path #"/")))
+
+;; Set the hidden ratio inputs so the alphabetically-earlier item wins.
+(defn- set-ratio! [pg left-text right-text]
+  (let [[rl rr] (if (neg? (compare (leaf left-text) (leaf right-text)))
+                  [100 0]   ; left is earlier → prefer left
+                  [0 100])] ; right is earlier → prefer right
+    (page/evaluate pg (str "document.getElementById('vote-ratio-left').value='" rl "'"))
+    (page/evaluate pg (str "document.getElementById('vote-ratio-right').value='" rr "'"))))
 
 (defn vote-pool-flow! []
-  (println "\n━━━ browser vote pool (/vote?pool= seeds + follow next-pair sequence) ━━━\n")
+  (println (str "\n━━━ browser vote pool (all " total-pairs " pairs → sorted ranking) ━━━\n"))
 
   (common/letlocals
    (bind build (common/run-cargo-build-release! ["slugsocial-server"]))
@@ -54,7 +68,6 @@
 
      (let [alice-token (oauth/fetch-bearer-token! base-url :username "alice")
            thread-tag  "browser-vote-pool"
-           ;; seed ~/pool/a through ~/pool/j as items with bodies
            item-lines  (str/join "\n"
                                  (map (fn [l] (str "~/pool/" l " {" l "}")) letters))
            raw         (str "# " thread-tag "\n\n~/pool {root}\n" item-lines "\n")
@@ -77,51 +90,57 @@
                (page/navigate pg (str base-url "/login"))
                (is (wait-for-text pg "body" "@alice" 15000) "alice session after login")
 
-               ;; Enter via pool URL — page picks first pair automatically.
                (page/navigate pg pool-url)
                (is (wait-for-text pg "body.view-vote-compare" "compare" 15000)
                    "pool entry: vote compare page loads")
 
-               ;; Verify the initial pair is within the pool.
-               (let [pair-text (element-text pg ".vote-compare-pair")]
-                 (is (and (string? pair-text) (str/includes? pair-text "~/pool/"))
-                     (str "initial pair is within ~/pool: " pair-text)))
-
-               ;; Follow vote → next-pair sequence up to 15 iterations.
-               (let [votes-cast
-                     (loop [i 0]
-                       (if (>= i 15)
-                         i
-                         (let [explanation (str "pool vote " i " reason")]
-                           (locator/fill (page/locator pg "#vote-explain") explanation)
-                           (locator/click (page/locator pg "#vote-compare-form button[type=submit]"))
-                           ;; Wait for edge history morph confirming the vote landed.
-                           (if-not (wait-for-text pg "ul.vote-edge-history" explanation 20000)
-                             (do (println "  vote" i "history morph timed out — stopping")
-                                 i)
-                             (let [has-next (wait-for-text pg "[data-testid=\"vote-next-pair\"]"
-                                                           "next pair" 8000)]
-                               (if-not has-next
-                                 ;; "no next pair" — pool exhausted.
-                                 (do (println "  no next pair after vote" i " — pool exhausted")
-                                     (inc i))
-                                 (do
-                                   ;; Verify the pair on this page is within the pool before advancing.
-                                   (let [pt (element-text pg ".vote-compare-pair")]
-                                     (is (and (string? pt) (str/includes? pt "~/pool/"))
-                                         (str "pair at vote " i " is within ~/pool: " pt)))
-                                   (locator/click (page/locator pg "[data-testid=\"vote-next-pair\"]"))
-                                   ;; Wait for next pair to load.
-                                   (wait-for-text pg "body.view-vote-compare" "compare" 10000)
-                                   (recur (inc i)))))))))]
-
-                 (is (>= votes-cast 1) (str "cast at least 1 vote, got: " votes-cast))
-                 (println (str "  pool voting sequence complete: " votes-cast " vote(s) cast")))
-
-               ;; After the sequence, the current page is still a pool-scoped vote page.
-               (let [url (page/url pg)]
-                 (is (str/includes? (or url "") "/vote")
-                     (str "still on /vote after sequence: " url))))))))
+               ;; Vote all 45 pairs, always preferring the alphabetically-earlier item.
+               (loop [votes-cast 0]
+                 (when (< votes-cast total-pairs)
+                   (let [left-text  (element-text pg ".vote-compare-left code")
+                         right-text (element-text pg ".vote-compare-right code")]
+                     (is (str/includes? left-text "~/pool/")
+                         (str "vote " votes-cast ": left is in pool: " left-text))
+                     (is (str/includes? right-text "~/pool/")
+                         (str "vote " votes-cast ": right is in pool: " right-text))
+                     (set-ratio! pg left-text right-text)
+                     (let [winner (if (neg? (compare (leaf left-text) (leaf right-text)))
+                                    (leaf left-text) (leaf right-text))]
+                       (locator/fill (page/locator pg "#vote-explain")
+                                     (str "prefer " winner)))
+                     (locator/click (page/locator pg "#vote-compare-form button[type=submit]"))
+                     (is (wait-for-text pg "ul.vote-edge-history" "prefer " 20000)
+                         (str "vote " votes-cast " appears in edge history"))
+                     (when (< (inc votes-cast) total-pairs)
+                       (is (wait-for-text pg "[data-testid=\"vote-next-pair\"]" "next pair" 8000)
+                           (str "next pair available after vote " votes-cast))
+                       (locator/click (page/locator pg "[data-testid=\"vote-next-pair\"]"))
+                       (is (wait-for-text pg "body.view-vote-compare" "compare" 10000)
+                           (str "vote page loaded for pair " (inc votes-cast))))
+                     (recur (inc votes-cast)))))
+
+               (println (str "  cast all " total-pairs " votes"))
+
+               ;; Query the ranking via RPC and assert alphabetical order.
+               (let [rank-resp  (oauth/http-post-json
+                                 (str base-url "/api/v0/rpc")
+                                 [{"GetGardenRank" {"room"        "public"
+                                                    "parent_path" "~/pool"}}]
+                                 :headers {"Authorization" (str "Bearer " alice-token)})
+                     rank-json  (json/parse-string (:body rank-resp) true)
+                     result     (get-in rank-json [:results 0 :result :GardenRank])
+                     components (:components result)
+                     unranked   (:unranked_items result)
+                     ranked     (mapv :item (mapcat :ranking components))
+                     ranked-leaves (mapv #(last (str/split % #"[/~]+")) ranked)]
+                 (is (= 1 (count components))
+                     (str "all 10 items form one connected component (got " (count components) ")"))
+                 (is (empty? unranked)
+                     (str "no unranked items (got " (count unranked) ")"))
+                 (is (= 10 (count ranked))
+                     (str "10 items ranked (got " (count ranked) ")"))
+                 (is (= letters ranked-leaves)
+                     (str "ranking is alphabetical a→j (got " ranked-leaves ")"))))))))
 
      (finally
        (when-some [s @!server] (common/kill-server s))

download full diff A

B — c_1c1c8e7a2de8 (tommy-mor)

message

[62d18183] room create path

diff preview

diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index dcb06a46045564f8f6f6acffbda6f88644d453cc..9828cba4d9c17b7cce3de597d8724609b2b2adbe 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -128,7 +128,7 @@ This means participation is collaborative by default. When you receive a compari
 ~/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).
+  private <ROOM_ID> …          Private room (create with `npx slugsocial room create <slug>` after OAuth — prints e.g. abc12xy/my-project).
 
 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).
 
@@ -144,7 +144,7 @@ Examples:
 
 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.
+Global (no room prefix): room, identity, whoami, feed, search, healthz.
 }
 
 ~/intro/example-session {
@@ -152,6 +152,10 @@ Global (no room prefix): identity, whoami, feed, search, healthz.
 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).
 
+# Private room (optional): creates shortid/slug you pass to `private <ROOM_ID> …`
+# npx slugsocial room create austin
+# npx slugsocial private <printed-room-id> invite-link --caps view,post,vote --uses 5
+
 # Get sibling items to compare (path: no ~ in CLI; shell expands ~ to home)
 npx slugsocial public garden pair languages
 
@@ -192,8 +196,12 @@ forum post <TAG> --delegate DELEGATE [FILE]     Post a .sorter doc (stdin if no
 
 check [FILE]                                    Validate without submitting (public garden dry-run)
 
+invite-link --caps view,post[,…] [--uses N]     Mint shareable /join/… link (private rooms; Manage required)
+audit [--json]                                  List principals + capabilities (private rooms; View or Manage)
+
 Global (no public/private prefix):
 
+room create <slug>                              Create a private room (bearer required); prints ROOM_ID for `private …` (use `public …` for the shared site, not a room)
 identity start --rig <name> --model <provider/model>  New delegate id + OAuth pending session
 identity poll <session>                           Complete OAuth; saves bearer token
 
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 8eda9f485bd1f7392f1e34be27176c21e20354eb..5b1a5845e90bfea9bd9a1e1af5744e97e566b5ae 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -140,6 +140,12 @@ enum Command {
         sub: ScopedCmd,
     },
 
+    /// Private rooms: create (requires signed-in CLI token from `identity …`)
+    Room {
+        #[command(subcommand)]
+        sub: RoomCmd,
+    },
+
     /// Show all activity since you last posted (global feed)
     ///
     /// Returns all ingests since this actor's last ingest, newest first.
@@ -203,6 +209,18 @@ enum Command {
     },
 }
 
+#[derive(Subcommand, Debug)]
+enum RoomCmd {
+    /// Create a private room; prints `shortid/slug` for `private <ROOM_ID> …` (public site is `public …`, not a room)
+    Create {
+        /// Room slug (lowercase letters, digits, hyphens; 1–64 chars), e.g. `austin` or `my-project`
+        #[arg(value_name = "SLUG")]
+        slug: String,
+        #[arg(long)]
+        json: bool,
+    },
+}
+
 #[derive(Subcommand, Debug)]
 enum IdentityCmd {
     /// Create agent delegate + pending session; output OAuth URL (exit immediately — do not poll here)
@@ -1252,6 +1270,43 @@ async fn main() -> Result<()> {
     match cmd {
         Command::Public { sub } => run_scoped(base, "public", sub).await?,
         Command::Private { room, sub } => run_scoped(base, &room, sub).await?,
+        Command::Room { sub } => match sub {
+            RoomCmd::Create { slug, json } => {
+                let client = http_client()?;
+                let bearer = effective_bearer().ok_or_else(|| {
+                    anyhow!(
+                        "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+                         then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+                    )
+                })?;
+                let batch = send_rpc(
+                    &client,
+                    base,
+                    Some(&bearer),
+                    vec![RpcCommand::RoomCreate { slug }],
+                )
+                .await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::RoomCreated { room_id } => {
+                        if json {
+                            println!(
+                                "{}",
+                                serde_json::to_string_pretty(&serde_json::json!({
+                                    "ok": true,
+                                    "room_id": room_id,
+                                }))?
+                            );
+                        } else {
+                            println!("{room_id}");
+                            println!();
+                            println!("Next: npx slugsocial private {room_id} forum post <TAG> --delegate '…' …");
+                            println!("      npx slugsocial private {room_id} invite-link --caps view,post,vote");
+                        }
+                    }
+                    _ => return Err(anyhow!("unexpected RPC result")),
+                }
+            }
+        },
 
         Command::Healthz { json } => {
             let client = http_client()?;
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index f6bbc3df71909a2da7403cd46fe4ea6ca130c692..7d384e938a526bdf6aa04d1bf21a54d3fcb57d7e 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -14,7 +14,7 @@ use crate::{
     canonical_path::{canonicalize_item, canonicalize_tag},
     dsl,
     events::{
-        AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility,
+        AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability,
     },
     identity::{parse_agent, parse_username},
     path_types::CanonicalItemUrl,
@@ -270,7 +270,7 @@ async fn rpc_post(
     let scope = scope_from_room_wire(&room_key);
 
     let is_private = !matches!(scope, ScopeId::Public);
-    if is_private && !reduced.rooms.contains_key(&room_key) {
+    if is_private && !reduced.rooms.contains(&room_key) {
         drop(reduced);
         return Err(("unknown room".into(), Some(format!("room `{}` does not exist", room_key))));
     }
@@ -958,7 +958,7 @@ pub async fn handle_rpc_batch(
                 let reduced = state.reduced.read().await;
                 line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room)))
             }
-            RpcCommand::RoomCreate { slug, visibility } => {
+            RpcCommand::RoomCreate { slug } => {
                 // Scope the first read so its guard drops before any nested `read().await` / `write().await`.
                 // A guard from `match verify(..., &*state.reduced.read().await)` would otherwise live for the
                 // whole `match` and deadlock here (tokio::sync::RwLock is not reentrant).
@@ -975,53 +975,42 @@ pub async fn handle_rpc_batch(
                         } else if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
                             line_err("slug must be lowercase alphanumeric with hyphens", None)
                         } else {
-                            match visibility.as_deref().unwrap_or("private") {
-                                "private" | "public" => {
-                                    let vis = if visibility.as_deref() == Some("public") {
-                                        ThreadVisibility::Public
-                                    } else {
-                                        ThreadVisibility::Private
-                                    };
-                                    let short_id = loop {
-                                        let id = gen_short_id();
-                                        if !state.reduced.read().await.rooms.contains_key(&format!("{id}/{slug}")) {
-                                            break id;
-                                        }
-                                    };
-                                    let room_id = format!("{short_id}/{slug}");
-                                    let ts = now_ms();
-                                    let tc_ev = Event::RoomCreated(RoomCreated {
-                                        ts,
-                                        room_id: room_id.clone(),
-                                        slug: slug.clone(),
-                                        owner: principal.clone(),
-                                        visibility: vis,
-                                    });
-                                    let ga_ev = Event::GrantAdded(GrantAdded {
-                                        ts,
-                                        room_id: room_id.clone(),
-                                        username: principal.clone(),
-                                        capabilities: vec![
-                                            ThreadCapability::View,
-                                            ThreadCapability::Post,
-                                            ThreadCapability::Vote,
-                                            ThreadCapability::AddItem,
-                                            ThreadCapability::Manage,
-                                        ],
-                                        granted_by: principal.clone(),
-                                    });
-                                    if let Err(e) = state.event_log.append(&tc_ev).await {
-                                        line_err(format!("{e}"), None)
-                                    } else if let Err(e) = state.event_log.append(&ga_ev).await {
-                                        line_err(format!("{e}"), None)
-                                    } else {
-                                        let mut r = state.reduced.write().await;
-                                        r.apply_event(tc_ev);
-                                        r.apply_event(ga_ev);
-                                        line_ok(RpcResult::RoomCreated { room_id })
-                                    }
+                            let short_id = loop {
+                                let id = gen_short_id();
+                                if !state.reduced.read().await.rooms.contains(&format!("{id}/{slug}")) {
+                                    break id;
                                 }
-                                other => line_err(format!("unknown visibility: {other}"), None),
+                            };
+                            let room_id = format!("{short_id}/{slug}");
+                            let ts = now_ms();
+                            let tc_ev = Event::RoomCreated(RoomCreated {
+                                ts,
+                                room_id: room_id.clone(),
+                                slug: slug.clone(),
+                                owner: principal.clone(),
+                            });
+                            let ga_ev = Event::GrantAdded(GrantAdded {
+                                ts,
+                                room_id: room_id.clone(),
+                                username: principal.clone(),
+                                capabilities: vec![
+                                    ThreadCapability::View,
+                                    ThreadCapability::Post,
+                                    ThreadCapability::Vot

… preview truncated; 10,232 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.