constitution · epochs · watch · epoch 3

comparison

c_7ec4b410de02 (tommy-mor) vs c_1c1c8e7a2de8 (tommy-mor)

download prompt · raw event · cmp_9dc12862ca86ea

council reasoning

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

Side B removes dead/unused complexity (ThreadVisibility, unused RoomState field, HashMap→HashSet simplification) and adds a genuinely useful CLI command (`room create`) with docs and tests updated across multiple layers (rpc.rs, events.rs, reducer.rs, timeline.rs, CLI, integration tests, bb tests). Side A is a smaller, focused UI feature (clickable pin/unpin) with decent browser test coverage but touches fewer architectural layers and has more narrowly scoped value.

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

B adds a real CLI `room create` path and simplifies the domain by dropping ThreadVisibility/RoomState so rooms are a HashSet of private ids, touching RPC, events, reducer, types, docs, and tests—structural product surface. A only wires an existing set_garden_pin clear into ranked child pin glyphs plus CSS/test updates, a useful but localized UX polish.

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

Side B adds a new top-level CLI `room create` command wired through to the RPC, updates the server to support the simplified `RoomCreate` flow, removes the unused room visibility concept from events/reducer state, and updates documentation and tests accordingly. Side A is a solid UX improvement that makes ranked child pin icons submit the existing `set_garden_pin` unpin action and adds browser coverage, but it is a narrower interface enhancement compared with B's broader, lasting API and architecture simplification.

sides

A — c_7ec4b410de02 (tommy-mor)

message

[02cd761c] Make ranked child group pin icons clickable to unpin.

Lets users clear the garden pin from the ranked child groups list via the same POST /ui set_garden_pin flow as the HUD and item header.

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

diff preview

diff --git a/server/src/html/garden/pin.rs b/server/src/html/garden/pin.rs
index 1820d7fe7ee167ecbf5ad5124f23b2ab92ffb01f..b7ea92bbe67d6d499915edb607a3a0b36178d7f6 100644
--- a/server/src/html/garden/pin.rs
+++ b/server/src/html/garden/pin.rs
@@ -101,6 +101,7 @@ pub(super) fn child_row_pin_or_vote(
     row_item: &ItemId,
     pinned_room_and_item: Option<&(String, ItemId)>,
     scope_content: &ContentState,
+    next_path: &str,
 ) -> maud::Markup {
     let pin_matches_scope = pinned_room_and_item
         .map(|(r, _)| r == nav.room_wire.as_str())
@@ -108,12 +109,25 @@ pub(super) fn child_row_pin_or_vote(
     let pinned_item = pinned_room_and_item
         .filter(|_| pin_matches_scope)
         .map(|(_, i)| i);
+    let unpin_rpc = template_json_compact(&json!({
+        "action": "set_garden_pin",
+        "clear": true,
+        "room_wire": "",
+        "next": next_path,
+        "form_action": "/ui",
+    }))
+    .expect("unpin rpc json");
 
     html! {
         @if let Some(pi) = pinned_item {
             span class="ont-garden-child-actions" data-garden-room=(nav.room_wire.as_str()) {
                 @if pi == row_item {
-                    span class="ont-garden-pinned-here" title="Pinned" aria-label="Pinned" { "📌" }
+                    form method="POST" action="/ui" data-navigate="full" class="ont-garden-pin-form" {
+                        input type="hidden" name=(UI_RPC_FIELD) value=(unpin_rpc);
+                        button type="submit" class="ont-garden-pin-ico ont-garden-pin-ico-active" title="Unpin" aria-label="Unpin from HUD" {
+                            span class="ont-garden-pin-glyph" aria-hidden="true" { "📌" }
+                        }
+                    }
                 } @else {
                     @let nv = edge_vote_count_for_pair(scope_content, pi, row_item);
                     @let tip = format!(
diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs
index bd8cbce059ee789de6e8dc9b6f69f54beee2f994..7bfaadc0786b734db8973c53b82296a188d26e22 100644
--- a/server/src/html/garden/render.rs
+++ b/server/src/html/garden/render.rs
@@ -216,7 +216,7 @@ pub(super) async fn render_scope_view(
                                     @let item_url = item_href(r.item.as_str(), &nav);
                                     @let score_str = format!("{:.3}", r.score);
                                     li data-garden-item=(r.item.as_str()) {
-                                        (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), scope_content))
+                                        (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), scope_content, &next_for_pin))
                                         a class="item-link" href=(item_url) { code { (item_display_path(r.item.as_str())) } }
                                         span class="ont-rank-score" { (score_str) }
                                     }
@@ -232,7 +232,7 @@ pub(super) async fn render_scope_view(
                         ul class="ont-group-list" {
                             @for name in &model.child_rankings.unranked_items {
                                 li data-garden-item=(name.as_str()) {
-                                    (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), scope_content))
+                                    (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), scope_content, &next_for_pin))
                                     @let href = item_href(name.as_str(), &nav);
                                     a class="item-link" href=(href) { code { (item_display_path(name.as_str())) } }
                                 }
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index 2dabf3a7094cecd89d0c9674d27efe30cd01c010..b846ab86cb6eb5fb56be87efb054fb8b18082583 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -891,8 +891,7 @@ button.ont-pin-btn-active {
   vertical-align: middle;
 }
 button.ont-garden-pin-ico,
-a.ont-garden-vote-ico,
-span.ont-garden-pinned-here {
+a.ont-garden-vote-ico {
   font-size: 13px;
   line-height: 1;
   padding: 2px 6px;
@@ -910,6 +909,11 @@ span.ont-garden-pinned-here {
   align-items: center;
   justify-content: center;
 }
+form.ont-garden-pin-form {
+  display: inline;
+  margin: 0;
+  padding: 0;
+}
 a.ont-garden-vote-ico {
   gap: 4px;
 }
@@ -925,10 +929,14 @@ a.ont-garden-vote-ico:hover {
 span.ont-garden-vote-glyph {
   line-height: 1;
 }
-span.ont-garden-pinned-here {
+button.ont-garden-pin-ico-active {
   border-color: var(--link);
   color: var(--signal);
-  cursor: default;
+  background: color-mix(in srgb, var(--link) 12%, var(--g3));
+}
+button.ont-garden-pin-ico-active:hover {
+  color: var(--signal);
+  background: color-mix(in srgb, var(--link) 18%, var(--g4));
 }
 button.ont-garden-pin-ico:focus-visible {
   outline: 2px solid var(--link);
diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
index bdd9031b82154a5019782e6dcb8bf7589fafce09..5d4954812ad43f2ee7e63c71c7f0ae0b3e3fc95b 100644
--- a/server/static/theme_retro_craft.css
+++ b/server/static/theme_retro_craft.css
@@ -711,8 +711,7 @@ body.view-ontology span.ont-garden-vote-glyph {
   line-height: 1;
 }
 body.view-ontology button.ont-garden-pin-ico,
-body.view-ontology a.ont-garden-vote-ico,
-body.view-ontology span.ont-garden-pinned-here {
+body.view-ontology a.ont-garden-vote-ico {
   font-size: 0.95rem;
   line-height: 1;
   padding: 0.1rem 0.35rem;
@@ -732,10 +731,14 @@ body.view-ontology a.ont-garden-vote-ico:hover {
   border-color: #a68e6b;
   background: #ebe6dc;
 }
-body.view-ontology span.ont-garden-pinned-here {
+body.view-ontology button.ont-garden-pin-ico-active {
   border-color: #1a4a8c;
   background: color-mix(in srgb, #1a4a8c 10%, #f7f3eb);
-  cursor: default;
+  color: #0d2d5c;
+}
+body.view-ontology button.ont-garden-pin-ico-active:hover {
+  border-color: #a68e6b;
+  background: color-mix(in srgb, #1a4a8c 14%, #ebe6dc);
 }
 body.view-ontology form.ont-garden-pin-form {
   display: inline;
diff --git a/test/browser_garden_pin.clj b/test/browser_garden_pin.clj
index d4fa9ed60b45e872ec60e29307cfa61bcd9dd6b2..579976f4c2493a149f9eba081862ca07919f4fe9 100644
--- a/test/browser_garden_pin.clj
+++ b/test/browser_garden_pin.clj
@@ -58,10 +58,11 @@
      (let [alice-token (oauth/fetch-bearer-token! base-url :username "alice")
            thread-tag "browser-garden-pin"
            raw (str "# " thread-tag "\n\n"
-                    "~/gp-pin-a {alpha}\n"
-                    "~/gp-pin-b {beta}\n"
+                    "~/gp-parent {parent}\n"
+                    "~/gp-parent/pin-a {alpha}\n"
+                    "~/gp-parent/pin-b {beta}\n"
                     "{pin test vote}\n"
-                    "~/gp-pin-a 2:1 ~/gp-pin-b\n")
+                    "~/gp-parent/pin-a 2:1 ~/gp-parent/pin-b\n")
            post-resp (oauth/http-post-json
                       (str base-url "/api/v0/rpc")
                       [{"Post" {"room" "public"
@@ -77,23 +78,43 @@
              (core/with-page [pg (core/new-page-from-context ctx)]
                (page/navigate pg (str base-url "/login"))
                (is (wait-for-text pg "body" "@alice" 15000) "alice session after login")
-               (page/navigate pg (str base-url "/~/gp-pin-a"))
-               (is (wait-for-text pg ".ont-item-shell" "~/gp-pin-a" 15000) "on item a page")
+               (page/navigate pg (str base-url "/~/gp-parent/pin-a"))
+               (is (wait-for-text pg ".ont-item-shell" "~/gp-parent/pin-a" 15000) "on item a page")
                ;; Native form POST /ui (data-navigate=full) — not fetch/eval
                (locator/click (page/locator pg ".ont-item-pin-zone form.ont-pin-form button[type=submit]"))
-               (is (wait-for-text pg "#slug-pin-hud" "gp-pin-a" 15000)
+               (is (wait-for-text pg "#slug-pin-hud" "gp-parent/pin-a" 15000)
                    "HUD shows pinned item label after redirect")
-               (page/navigate pg (str base-url "/~/gp-pin-b"))
-               (is (wait-for-text pg ".ont-item-shell" "~/gp-pin-b" 15000) "on item b page")
+               (page/navigate pg (str base-url "/~/gp-parent/pin-b"))
+               (is (wait-for-text pg ".ont-item-shell" "~/gp-parent/pin-b" 15000) "on item b page")
                (is (wait-for-text pg "a.ont-vote-compare-btn" "vote" 10000)
                    "vote link visible vs pinned item")
-               ;; HUD clears pin (POST set_garden_pin clear), not navigate to item
+               ;; Unpin from ranked child groups on parent page
+               (page/navigate pg (str base-url "/~/gp-parent"))
+               (is (wait-for-text pg ".ont-tab-panel-children" "ranked child groups" 15000)
+                   "parent page shows ranked child groups")
+               (is (wait-for-text pg ".ont-ranking-list button.ont-garden-pin-ico-active" "📌" 10000)
+                   "pinned row shows active pin in ranked list")
+               (locator/click (page/locator pg ".ont-ranking-list button.ont-garden-pin-ico-active"))
+               (is (wait-for-absence-substr pg "#slug-pin-hud" "gp-parent/pin-a" 15000)
+                   "HUD clears after unpin from ranked child list")
+               (is (wait-for-absence-substr pg ".ont-ranking-list" "ont-garden-vote-ico" 10000)
+                   "vote icons removed from ranked list after unpin")
+               (page/navigate pg (str base-url "/~/gp-parent/pin-b"))
+               (is (wait-for-text pg ".ont-item-shell" "~/gp-parent/pin-b" 15000) "on item b page again")
+               (is (wait-for-absence-substr pg "body" "ont-vote-compare-btn" 15000)
+                   "compare vote CTA removed after ranked-list unpin")
+               ;; HUD unpin still works from item page context
+               (page/navigate pg (str base-url "/~/gp-parent/pin-a"))
+               (locator/click (page/locator pg ".ont-item-pin-zone form.ont-pin-form button[type=submit]"))
+               (is (wait-for-text pg "#slug-pin-hud" "gp-parent/pin-a" 15000)
+                   "re-pin from item page for HUD unpin test")
+               (page/navigate pg (str base-url "/~/gp-parent/pin-b"))
                (locator/click (page/locator pg "#slug-pin-hud button.slug-pin-hud-unpin-btn"))
-               (is (wait-for-absence-substr pg "#slug-pin-hud" "gp-pin-a" 15000)
+               (is (wait-for-absence-substr pg "#slug-pin-hud" "gp-parent/pin-a" 15000)
                    "HUD clears after unpin from HUD button")
                (is (wait-for-absence-substr pg "body" "ont-vote-compare-btn" 15000)
                    "compare vote CTA removed after HUD unpin on same reload")
-               (is (str/includes? (or (page/url pg) "") "/~/gp-pin-b")
+               (is (str/includes? (or (page/url pg) "") "/~/gp-parent/pin-b")
                    "still on item b after HUD unpin"))))))
 
      (finally

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.