constitution · epochs · watch · epoch 3

comparison

c_552f408ae0da (tommy-mor) vs c_2f5d9e0370f8 (tommy-mor)

download prompt · raw event · cmp_ba1933556832ef

council reasoning

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

Side B implements a real DSL syntax change (explanation-first votes) with careful parser rework, backward-compat rejection logic, and updates across docs, fixtures, server tests, and browser tests, showing broad, consistent propagation of a design decision. Side A adds a useful but narrow RPC/CLI feature (RoomList) with good test coverage, but it's a smaller, more contained addition compared to B's cross-cutting language/parser change that touches core DSL semantics used throughout the system.

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

B redesigns the core sorter DSL so vote explanations lead and verdicts trail, with real parser/UI changes (dsl.rs, ui_html vote formatting, editor placeholder) that redefine how judgments are authored. A adds a useful RoomList RPC/CLI path with solid isolation tests, but it is incremental surface API work versus B’s lasting language-level design shift (even though much of B’s diff is fixture propagation).

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

Side A adds a new end-to-end capability: a RoomList RPC, CLI `room list` subcommand, shared request/response types, server implementation that filters rooms by authenticated principal's grants, and integration tests verifying per-user isolation and grant behavior. Side B primarily changes DSL syntax from trailing to leading vote explanations, updating the parser, UI generation, documentation, and many fixtures/tests to match; while substantial, it is largely a syntax migration rather than introducing comparable new functionality.

sides

A — c_552f408ae0da (tommy-mor)

message

[9acdf18a] feat: add RoomList RPC command and CLI room list subcommand

Returns all rooms the authenticated principal has a grant in.
Includes integration tests proving per-user isolation: users only
see rooms they have been explicitly granted, not all rooms in the system.

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

diff preview

diff --git a/bb.edn b/bb.edn
index f7c53eb2d5def514a8f2c480ac420416205db14e..8dc6a5be7321de198cbb5939842a33b8c5d52625 100644
--- a/bb.edn
+++ b/bb.edn
@@ -47,16 +47,18 @@
                                            "RUST_LOG"      "info"})})))}
 
   test
-  {:doc "Full test suite: integration + auth + grants + invites"
+  {:doc "Full test suite: integration + auth + grants + invites + room-list"
    :requires ([test.integration :as integration]
               [test.auth :as auth]
               [test.grants :as grants]
-              [test.invites :as invites])
+              [test.invites :as invites]
+              [test.room-list :as room-list])
    :task (do
            (integration/integration)
            (auth/auth-test)
            (grants/grants-test)
-           (invites/invites-test))}
+           (invites/invites-test)
+           (room-list/room-list-test))}
 
   walkthrough-fixture
   {:doc "Run local server + mock OAuth + seeded walkthrough data for manual browser demos"
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 69492cb5417a0a19c38f8cacbeadd103bd905b6f..b008cf377bb360aaae666d2dfd4b5e13dedb204a 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -219,6 +219,12 @@ enum RoomCmd {
         #[arg(long)]
         json: bool,
     },
+    /// List rooms the authenticated user has access to
+    List {
+        /// Output as JSON for agent parsing
+        #[arg(long)]
+        json: bool,
+    },
 }
 
 #[derive(Subcommand, Debug)]
@@ -1319,6 +1325,38 @@ async fn main() -> Result<()> {
                     _ => return Err(anyhow!("unexpected RPC result")),
                 }
             }
+            RoomCmd::List { 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::RoomList],
+                )
+                .await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::RoomList(resp) => {
+                        if json {
+                            println!("{}", serde_json::to_string_pretty(&resp)?);
+                        } else {
+                            if resp.rooms.is_empty() {
+                                println!("no rooms");
+                            } else {
+                                for room in &resp.rooms {
+                                    println!("{room}");
+                                }
+                            }
+                        }
+                    }
+                    _ => return Err(anyhow!("unexpected RPC result")),
+                }
+            }
         },
 
         Command::Healthz { json } => {
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index ed4800e7cbdeaf04e72192c191e71354e603c1fe..f1ee6d35b95a1a28490823e907b8f4dc5c091b94 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -1345,6 +1345,25 @@ pub async fn handle_rpc_batch(
                     }
                 }
             }
+            RpcCommand::RoomList => {
+                let principal = {
+                    let reduced = state.reduced.read().await;
+                    verify_bearer_principal(&headers, &*reduced)
+                };
+                match principal {
+                    Err((_, m)) => line_err(m, None),
+                    Ok(principal) => {
+                        let reduced = state.reduced.read().await;
+                        let rooms: Vec<String> = reduced
+                            .grants
+                            .iter()
+                            .filter(|(_, members)| members.contains_key(&principal))
+                            .map(|(room, _)| room.clone())
+                            .collect();
+                        line_ok(RpcResult::RoomList(RoomListResponse { rooms }))
+                    }
+                }
+            }
             RpcCommand::RoomRevoke {
                 room,
                 username,
diff --git a/test/room_list.clj b/test/room_list.clj
new file mode 100644
index 0000000000000000000000000000000000000000..a089a722ac8d5f822f47d5511a46f671d61d46cf
--- /dev/null
+++ b/test/room_list.clj
@@ -0,0 +1,158 @@
+(ns test.room-list
+  "Room list integration test: list rooms user has access to via POST /api/v0/rpc.
+
+  Covers:
+  - user with no rooms -> empty list
+  - user with one room -> list contains that room
+  - user with multiple rooms -> list contains all rooms"
+  (:require [babashka.fs :as fs]
+            [cheshire.core :as json]
+            [clojure.set :as set]
+            [test.common :as common]
+            [test.oauth :as oauth]))
+
+(def ^:private counts (atom {:pass 0 :fail 0}))
+
+(defn- assert! [pred msg]
+  (common/test-assert! counts pred msg))
+
+(defn- bearer [token] {"Authorization" (str "Bearer " token)})
+
+(defn- rpc-batch! [base-url token cmds]
+  (let [resp (oauth/http-post-json (str base-url "/api/v0/rpc") cmds :headers (bearer token))]
+    {:status (:status resp)
+     :parsed (json/parse-string (:body resp) false)}))
+
+(defn- rpc-line-ok? [parsed]
+  (true? (get-in parsed ["results" 0 "ok"])))
+
+(defn- register-user! [base-url session-agent username]
+  (oauth/complete-registration! base-url
+                                :agent session-agent
+                                :username username
+                                :assert! (fn [pred msg] (assert! pred msg))))
+
+(defn room-list-test [& _args]
+  (println "\n━━━ room list integration check ━━━\n")
+  (reset! counts {:pass 0 :fail 0})
+
+  (println "building server binary…")
+  (common/letlocals
+   (bind build (common/run-cargo-build-release! ["slugsocial-server"]))
+   (assert! (zero? (:exit build)) "cargo build succeeds")
+   (bind server-bin "target/release/slugsocial-server")
+
+   (bind tmp-dir (str (fs/create-temp-dir {:prefix "slug-room-list-"})))
+   (bind slug-port (common/pick-port))
+   (bind google-port (common/pick-port))
+   (bind base-url (str "http://127.0.0.1:" slug-port))
+   (bind google-url (str "http://127.0.0.1:" google-port))
+
+   (bind !server (atom nil))
+   (bind !google (atom nil))
+
+   (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port))
+   (try
+     (println (str "starting mock google on :" google-port))
+     (reset! !google (oauth/start-mock-google google-port
+                                              :google-users ["google-user-alice"
+                                                             "google-user-bob"
+                                                             "google-user-carol"]))
+
+     (println (str "starting server on :" slug-port))
+     (reset! !server (common/start-server server-bin server-env))
+     (assert! (common/wait-for-server base-url 10000) "server responds to /healthz")
+
+     (println "\nregistering alice, bob, carol…")
+     (let [alice-token (register-user! base-url
+                                       "00000000-0000-0000-0000-000000000001:test:local/dev"
+                                       "alice")
+           bob-token   (register-user! base-url
+                                       "00000000-0000-0000-0000-000000000002:test:local/dev"
+                                       "bob")
+           carol-token (register-user! base-url
+                                       "00000000-0000-0000-0000-000000000003:test:local/dev"
+                                       "carol")
+
+           ;; Alice creates two private rooms
+           _ (println "\nalice creates two rooms…")
+           room-id-1 (-> (rpc-batch! base-url alice-token [{"RoomCreate" {"slug" "alice-room-one"}}])
+                         (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+           _ (assert! (some? room-id-1) "alice room-one created")
+           room-id-2 (-> (rpc-batch! base-url alice-token [{"RoomCreate" {"slug" "alice-room-two"}}])
+                         (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+           _ (assert! (some? room-id-2) "alice room-two created")
+
+           ;; Carol creates her own room
+           _ (println "carol creates her own room…")
+           carol-room (-> (rpc-batch! base-url carol-token [{"RoomCreate" {"slug" "carol-room"}}])
+                          (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+           _ (assert! (some? carol-room) "carol room created")]
+
+       ;; --- isolation: alice only sees her rooms, not carol's ---
+       (println "\nalice sees her 2 rooms but not carol's…")
+       (let [rooms (-> (rpc-batch! base-url alice-token ["RoomList"])
+                       (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+                       set)]
+         (assert! (= #{room-id-1 room-id-2} rooms)
+                  "alice sees exactly her 2 rooms")
+         (assert! (not (contains? rooms carol-room))
+                  "alice does NOT see carol's room"))
+
+       ;; --- isolation: carol only sees her room, not alice's ---
+       (println "carol sees only her room…")
+       (let [rooms (-> (rpc-batch! base-url carol-token ["RoomList"])
+                       (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+                       set)]
+         (assert! (= #{carol-room} rooms)
+                  "carol sees exactly her own room")
+         (assert! (not (contains? rooms room-id-1))
+                  "carol does NOT see alice's room-one")
+         (assert! (not (contains? rooms room-id-2))
+                  "carol does NOT see alice's room-two"))
+
+       ;; --- bob sees nothing yet: alice has 3 rooms total but bob is in none ---
+       (println "bob (no grants) sees no rooms despite 3 existing…")
+       (let [rooms (-> (rpc-batch! base-url bob-token ["RoomList"])
+                       (get-in [:parsed "results" 0 "result" "RoomList" "rooms"]))]
+         (assert! (zero? (count rooms))
+                  "bob sees 0 rooms even though 3 exist in the system"))
+
+       ;; --- partial grant: alice grants bob room-one only ---
+       (println "\nalice grants bob view on room-one only…")
+       (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token
+                                                   [{"RoomGrant" {"room" room-id-1
+                                                                  "username" "bob"
+                                                                  "capabilities" ["view"]}}])))
+                "grant ok")
+
+       ;; bob sees room-one but NOT room-two or carol's room
+       (println "bob sees room-one but not room-two or carol's room…")
+       (let [rooms (-> (rpc-batch! base-url bob-token ["RoomList"])
+                       (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+                       set)]
+         (assert! (= #{room-id-1} rooms)
+                  "bob sees exactly room-one")
+         (assert! (not (contains? rooms room-id-2))
+                  "bob does NOT see alice's room-two (not granted)")
+         (assert! (not (contains? rooms carol-room))
+                  "bob does NOT see carol's room (not granted)"))
+
+       ;; alice's view is unchanged
+       (println "alice's view unchanged after granting bob…")
+       (let [rooms (-> (rpc-batch! base-url alice-token ["RoomList"])
+                       (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+                       set)]
+         (assert! (= #{room-id-1 room-id-2} rooms)
+                  "alice still sees exactly her 2 rooms after granting bob")))
+
+     (fin

… preview truncated; 2,078 characters omitted

download full diff A

B — c_2f5d9e0370f8 (tommy-mor)

message

[6bda2635] Use title-first items and explanation-first votes (#135)

* Require block-first sorter DSL statements

Co-authored-by: tommy <thmorriss@gmail.com>

* Use title-first items with explanation-first votes

Co-authored-by: tommy <thmorriss@gmail.com>

* Update garden vote test DSL fixtures

Co-authored-by: tommy <thmorriss@gmail.com>

* Update browser vote DSL payloads

Co-authored-by: tommy <thmorriss@gmail.com>

---------

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

diff preview

diff --git a/cli/DSL.txt b/cli/DSL.txt
index 18f69ef25f01583fddf9fc90e077bb2c3fa72bb6..c9b12bf0edaf73ad252f0a202b7fe61e311df50a 100644
--- a/cli/DSL.txt
+++ b/cli/DSL.txt
@@ -18,9 +18,20 @@ Blank lines are preserved to maintain paragraph structure.
 ~/item/a {itembody}
 ~/python { A high-level scripting language }
 
-~/item/a > ~/python { Item A is better because of X. }
-~/python 3:1 ~/go { Python's ecosystem is much richer than Go's. }
-https://example.com/lang = ~/go { They are equally good in this context. }
+{
+Item A is better because of X.
+}
+~/item/a > ~/python
+
+{
+Python's ecosystem is much richer than Go's.
+}
+~/python 3:1 ~/go
+
+{
+They are equally good in this context.
+}
+https://example.com/lang = ~/go
 ```
 
 SYNTAX RULES
@@ -35,7 +46,7 @@ Starts a thread. Tag allows alphanumeric, `-`, `_`, and `/`. Subtitle max 100 ch
 ~/<local-item-path> { description }
 ```
 Defines an ontology item (garden layer). Paths can be nested (e.g. `~/languages/python`).
-A leading `/` alone is **not** allowed in the DSL — use `~/` only. Descriptions (bodies) are wrapped in `{}`. Can be adjacent (e.g. `~/arrived{ready}`).
+A leading `/` alone is **not** allowed in the DSL — use `~/` only. Descriptions (bodies) are wrapped in `{}` and follow the item path.
 
 ```sorter
 https://example.com/item { description }
@@ -46,7 +57,10 @@ Canonicalization rules for URLs:
 - `~/` and `https://slug.social/~/` map to the same local item path.
 
 ```sorter
-<item1> <comparison> <item2> { required explanation }
+{
+required explanation
+}
+<item1> <comparison> <item2>
 ```
 Compares two items. The explanation is REQUIRED.
 Comparisons:
@@ -65,7 +79,8 @@ When writing bodies or explanations, you can use braces `{}` and code blocks wit
 3. Single braces: { ... }
 
 ```sorter
-~/code { Here is a block: ```def foo(): return {"a": 1}``` }
+{ Here is a block: ```def foo(): return {"a": 1}``` }
+~/code
 ```
 
 STYLE
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index 7ea1c2649cb4a323b46f0bf389025079a9c9ab43..86accba72ecea547215d947fb6552e0ead25687c 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -83,7 +83,8 @@ Item definitions (attaches a description to an item):
   ~/thread/item { description }
 
 Comparisons:
-  ~/thread/item-a 3:1 ~/thread/item-b { reasoning }
+  { reasoning }
+  ~/thread/item-a 3:1 ~/thread/item-b
 
 Ratio formats:
   3:1   left is 3x better than right
@@ -95,8 +96,7 @@ Shorthand:
   <     means 1:2 (right is better)
   =     means 1:1 (equal)
 
-Bodies can attach without whitespace:
-  ~/thread/item{Description here}
+Item bodies follow the item path. Vote explanations come first; the comparison is the verdict line.
 }
 
 You can write any prose in your posts. These won't be part of the garden but only the thread.
@@ -165,7 +165,8 @@ npx slugsocial public forum post languages --delegate '7a3b9c2d-1234-5678-90ab-c
 
 ~/languages/python { A high-level language focused on readability. }
 ~/languages/rust { A systems language focused on safety and performance. }
-~/languages/python 2:1 ~/languages/rust { Python has simpler syntax for beginners - fewer symbols, explicit over implicit.  Rust's borrow checker adds cognitive load even for simple programs.  Both are readable once learned, but Python's learning curve is gentler.  }
+{ Python has simpler syntax for beginners - fewer symbols, explicit over implicit.  Rust's borrow checker adds cognitive load even for simple programs.  Both are readable once learned, but Python's learning curve is gentler.  }
+~/languages/python 2:1 ~/languages/rust
 EOF
 
 # See current ranking
diff --git a/ideas/single-thread.md b/ideas/single-thread.md
index 86230fe6119c31045c21dbfcb12311b323c02fa8..0efb7199524cc3b308b1922c03e3a99193e4586c 100644
--- a/ideas/single-thread.md
+++ b/ideas/single-thread.md
@@ -15,7 +15,8 @@ Previously a `.sorter` document could scatter `#tags` throughout:
 ~/languages/rust {A systems language.}
 #tools
 ~/tools/cargo {Rust's build system.}
-~/languages/rust 2:1 ~/tools/cargo {Rust is more foundational than its tooling.}
+{Rust is more foundational than its tooling.}
+~/languages/rust 2:1 ~/tools/cargo
 ```
 
 The system would fan the ingest into both `#languages` and `#tools` — the same
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 9f3c1cc21c2ae157c024a447980a97e190c8f066..920e967b47852ea82fa61b84c457ae3582dd9800 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -72,16 +72,16 @@ mod tests {
         apply_ingest(
             &mut reduced,
             1,
-            "~/t/a {a}\n~/t/b {b}\n~/t/a 2:1 ~/t/b {because}\n",
+            "~/t/a {a}\n~/t/b {b}\n{because}\n~/t/a 2:1 ~/t/b\n",
         );
-        let text = "~/t/a 1:1 ~/t/b {equal}\n";
+        let text = "{equal}\n~/t/a 1:1 ~/t/b\n";
         validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap();
     }
 
     #[test]
     fn validate_ingest_document_rejects_vote_on_undefined_item() {
         let reduced = ReducerState::default();
-        let text = "~/t/a {x}\n~/t/b 1:1 ~/t/missing {why}\n";
+        let text = "~/t/a {x}\n{why}\n~/t/b 1:1 ~/t/missing\n";
         let err = validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err();
         assert_eq!(err.0, StatusCode::BAD_REQUEST);
         assert!(err.1.contains("undefined item"));
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 696e7b3605e2e68aee0351116c494c2958506add..7cbda3876451687aa7a55547fc06bbe86ac9d260 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -222,13 +222,13 @@ async fn dispatch_ui_action(
             }
 
             let text = format!(
-                "@{}\n{} {}:{} {} {{\n{}\n}}\n",
+                "@{}\n{{\n{}\n}}\n{} {}:{} {}\n",
                 crate::api::auth::WEB_BROWSER_AGENT,
+                exp,
                 left_id.as_str(),
                 rl,
                 rr,
-                right_id.as_str(),
-                exp
+                right_id.as_str()
             );
 
             match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {
diff --git a/server/src/dsl.rs b/server/src/dsl.rs
index 7962342bd023b3b5061a684d84d4ab164134b111..def8b497c71567016a522648b9630d7038163e41 100644
--- a/server/src/dsl.rs
+++ b/server/src/dsl.rs
@@ -11,16 +11,21 @@ pub struct Document {
 /// A single statement in the DSL (or prose when using `parse_full`).
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum Stmt {
-    Item { title: String, body: Option<String> },
+    Item {
+        title: String,
+        body: Option<String>,
+    },
     Vote {
         item1: String,
         item2: String,
         ratio_left: i32,
         ratio_right: i32,
-        /// Required non-empty explanation (from trailing `{ ... }`).
+        /// Required non-empty explanation (from leading `{ ... }`).
         explanation: String,
     },
-    Prose { text: String },
+    Prose {
+        text: String,
+    },
 }
 
 #[derive(Debug, thiserror::Error)]
@@ -391,71 +396,46 @@ fn parse_comparison_at(s: &str, i: usize) -> Option<((i32, i32), usize)> {
     Some(((left, right), j))
 }
 
-fn parse_item_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, DslError> {
-    // item: ("~/" | "https://..." | "http://...") item_ref body?
-    // vote: same for both operands.
-    //
-    // Important: body token can be adjacent to the item name (no whitespace),
-    // e.g. "~/arrived{...}" -> "~/arrived__BLOCK_x__".
-    let s = stripped;
-    let bytes = s.as_bytes();
-    if bytes.is_empty() {
-        return Err(DslError::Parse("missing item statement".to_string()));
+fn parse_block_prefixed_statement(
+    block_token: &str,
+    tail: &str,
+    masker: &BlockMasker,
+) -> Result<Stmt, DslError> {
+    // vote: block item_ref comparison item_ref
+    let s = tail.trim_start();
+    if s.is_empty() {
+        return Err(DslError::Parse(
+            "missing vote statement after leading explanation block".to_string(),
+        ));
     }
 
     let (item1, j) =
         parse_item_name_at(s, 0).ok_or_else(|| DslError::Parse("invalid item name".to_string()))?;
+    let explanation = masker.extract_body(block_token);
+    let mut i = skip_ws(s, j);
 
-    // Either we have:
-    // - immediate/whitespace block token => Item
-    // - comparison => Vote
-    // - whitespace then block token => Item
-    // - whitespace then comparison => Vote
-    let i = skip_ws(s, j);
-
-    // If next is end or a block token => Item.
     if i >= s.len() {
-        return Ok(Stmt::Item {
-            title: item1,
-            body: None,
-        });
-    }
-    if let Some((tok, end)) = parse_block_token_at(s, i) {
-        let body = masker.extract_body(&tok);
-        let tail = s[end..].trim();
-        if !tail.is_empty() {
-            return Err(DslError::Parse("extra tokens after item".to_string()));
-        }
-        return Ok(Stmt::Item {
-            title: item1,
-            body: Some(body),
-        });
+        return Err(DslError::Parse(
+            "leading `{ ... }` blocks are vote explanations; item bodies belong after item paths"
+                .to_string(),
+        ));
     }
 
-    // Otherwise parse comparison then "/item2" then REQUIRED body.
-    let ((ratio_left, ratio_right), mut k) = parse_comparison_at(s, i)
+    let ((ratio_left, ratio_right), k) = parse_comparison_at(s, i)
         .ok_or_else(|| DslError::Parse(format!("invalid comparison near: {}", &s[i..])))?;
     if ratio_left == 0 && ratio_right == 0 {
         return Err(DslError::Parse(
             "vote ratio 0:0 is invalid; use 1:1 for a tie or omit the vote".to_string(),
         ));
     }
-    k = skip_ws(s, k);
-    let (item2, mut m) = parse_item_name_at(s, k)
+    i = skip_ws(s, k);
+    let (item2, m) = parse_item_name_at(s, i)
         .ok_or_else(|| DslError::Parse("invalid rhs item name".to_string()))?;
-    m = skip_ws(s, m);
-
-    let Some((tok, end)) = parse_block_token_at(s, m) else {
-        return Err(DslError::Parse(
-            "missing vote explanation (add a trailing `{ ... }`)".to_string(),
-        ));
-    };
-    let explanation = masker.extract_body(&tok);
+    i = skip_ws(s, m);
     if explanation.trim().is_empty() {
         return Err(DslError::Parse("empty vote explanation".to_string()));
     }
-    m = end;
-    let tail = s[m..].trim();
+    let tail = s[i..].trim();
     if !tail.is_empty() {
         return Err(DslError::Parse("extra tokens after vote".to_string()));
     }
@@ -469,6 +449,35 @@ fn parse_item_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, Ds
     })
 }
 
+fn parse_item_definition_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, DslError> {
+    let (item1, j) =
+        parse_item_name_at(stripped, 0).ok_or_else(|| DslError::Parse("invalid item name".to_string()))?;
+    let i = skip_ws(stripped, j);
+
+    if i >= stripped.len() {
+        return Ok(Stmt::Item {
+            title: item1,
+            body: None,
+        });
+    }
+
+    if let Some((tok, end)) = parse_block_token_at(stripped, i) {
+        let body = masker.extract_body(&tok);
+        let tail = stripped[end..].trim();
+        if !tail.is_empty() {
+            return Err(DslError::Parse("extra tokens after item".to_string()));
+        }
+        return Ok(Stmt::Item {
+            title: item1,
+            body: Some(body),
+        });
+    }
+
+    Err(DslError::Parse(
+        "vote explanations must start with a `{ ... }` block before the comparison".to_string(),
+    ))
+}
+
 fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result<Vec<Stmt>, DslError> {
     let stripped = masked_line.trim_start();
     if stripped.is_empty() {
@@ -477,27 +486,28 @@ fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result<Vec<Stmt>, DslE
     let first = stripped.chars().next().unwrap();
     match first {
         '#' => Err(DslError::Parse("not a DS

… preview truncated; 47,409 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.