constitution · epochs · watch · epoch 3

comparison

c_55f1cdf12e22 (tommy-mor) vs c_11ce057e37af (tommy-mor)

download prompt · raw event · cmp_234fac28d5a047

council reasoning

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

Side A adds a large invite feature with real working mint/redeem/audit flow and end-to-end tests, but it also introduces substantial dead scaffolding — Event::InviteMinted/InviteRedeemed, ActiveInviteState, room_timeline, and an entire new timeline.rs module — none of which are ever wired into the actual RPC/reducer flow (invites are minted/redeemed purely via the separate ephemeral state.invites map), leaving confusing, unused duplicate machinery. Side B is a smaller but coherent and fully-integrated fix: it makes block masking deterministic/typed, correctly tokenizes prose item refs (fixing real link/URL-boundary/code-fence bugs), tightens DSL item-body rules, and backs every change with focused unit tests, with no orphaned code paths.

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

A delivers a complete invite-link feature (mint RPC, /join redemption into grants, CLI, audit, room timeline merge into ThreadItem, and an end-to-end invites test suite), which is a durable access-control capability. B is a focused but narrower DSL/linkify improvement (deterministic block kinds, braced bodies, prose tokenization of ~/ -/ https refs with fence/newline handling) that mainly refines existing garden rendering rather than adding a new product surface.

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

Side A implements a substantial new capability: an end-to-end invite system with invite minting, redemption during OAuth, room auditing, new RPCs/CLI commands, server routes, state management, and integration tests covering the workflow. Side B improves DSL parsing and HTML linkification by adding deterministic block masking, prose reference tokenization, URL handling, stricter braced item bodies, and tests, but these are narrower parser/UI enhancements compared with the lasting project functionality introduced in Side A.

sides

A — c_55f1cdf12e22 (tommy-mor)

message

[00be3a29] invite system

diff preview

diff --git a/bb.edn b/bb.edn
index 50be8232847e672b3f273a2fb25ddd1d12adb7e2..818f850765370d12d58f239e287764fc3649d78b 100644
--- a/bb.edn
+++ b/bb.edn
@@ -47,14 +47,16 @@
                                            "RUST_LOG"      "info"})})))}
 
   test
-  {:doc "Full test suite: integration + auth + grants"
+  {:doc "Full test suite: integration + auth + grants + invites"
    :requires ([test.integration :as integration]
               [test.auth :as auth]
-              [test.grants :as grants])
+              [test.grants :as grants]
+              [test.invites :as invites])
    :task (do
            (integration/integration)
            (auth/auth-test)
-           (grants/grants-test))}
+           (grants/grants-test)
+           (invites/invites-test))}
 
   perf
   {:doc "Performance test: concurrent HTTP requests to detect blocking I/O"
diff --git a/cli/src/main.rs b/cli/src/main.rs
index e5833b0b93d8e667b94c574ba2b0f8cb758ff3df..8eda9f485bd1f7392f1e34be27176c21e20354eb 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -105,6 +105,23 @@ enum ScopedCmd {
         #[arg(long)]
         json: bool,
     },
+
+    /// Mint a shareable invite link (24h TTL, in-memory until redeemed). Requires Manage on the room.
+    InviteLink {
+        /// Comma-separated: view, post, vote, add_item, manage
+        #[arg(long = "caps", value_delimiter = ',')]
+        caps: Vec<String>,
+        #[arg(long, default_value_t = 1)]
+        uses: usize,
+        #[arg(long)]
+        json: bool,
+    },
+
+    /// List principals granted access in this room (requires View or Manage)
+    Audit {
+        #[arg(long)]
+        json: bool,
+    },
 }
 
 #[derive(Subcommand, Debug)]
@@ -514,17 +531,35 @@ fn print_thread(resp: &ThreadDetailResponse) {
         .duration_since(std::time::UNIX_EPOCH)
         .unwrap_or_default()
         .as_millis() as i64;
-    if resp.total > resp.posts.len() {
-        let end = resp.offset + resp.posts.len();
-        eprintln!("# showing {}-{} of {} posts  (--offset N --limit N to paginate)", resp.offset, end.saturating_sub(1), resp.total);
+    if resp.total > resp.items.len() {
+        let end = resp.offset + resp.items.len();
+        eprintln!(
+            "# showing {}-{} of {} rows  (--offset N --limit N to paginate)",
+            resp.offset,
+            end.saturating_sub(1),
+            resp.total
+        );
     }
-    for (i, post) in resp.posts.iter().enumerate() {
-        let timeago = slug_types::timeago::timeago_compact(now_ms, post.ts);
-        let body = &post.body.trim();
-        println!("<post index=\"{}\" timeago=\"{}\">", post.index, timeago);
-        println!("{}", body);
-        println!("</post>");
-        if i + 1 < resp.posts.len() {
+    for (i, item) in resp.items.iter().enumerate() {
+        match item {
+            ThreadItem::Post {
+                index,
+                ts,
+                body,
+                ..
+            } => {
+                let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);
+                let body = body.trim();
+                println!("<post index=\"{}\" timeago=\"{}\">", index, timeago);
+                println!("{}", body);
+                println!("</post>");
+            }
+            ThreadItem::System { ts, text } => {
+                let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);
+                println!("<system timeago=\"{}\">{}</system>", timeago, text.trim());
+            }
+        }
+        if i + 1 < resp.items.len() {
             println!();
             println!();
         }
@@ -1036,6 +1071,95 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
                 }
             }
         },
+        ScopedCmd::InviteLink { caps, uses, json } => {
+            let caps: Vec<String> = caps
+                .into_iter()
+                .flat_map(|s| {
+                    s.split(',')
+                        .map(|p| p.trim().to_lowercase())
+                        .filter(|p| !p.is_empty())
+                        .collect::<Vec<_>>()
+                })
+                .collect();
+            if caps.is_empty() {
+                return Err(anyhow!("--caps is required (e.g. --caps view,post,vote)"));
+            }
+            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::RoomMintInvite {
+                    room: room.to_string(),
+                    capabilities: caps,
+                    max_uses: uses,
+                }],
+            )
+            .await?;
+            match rpc_line_ok(&batch.results[0])? {
+                RpcResult::RoomInviteMinted {
+                    invite_url,
+                    expires_at_ms,
+                    max_uses,
+                } => {
+                    if json {
+                        println!(
+                            "{}",
+                            serde_json::to_string_pretty(&serde_json::json!({
+                                "invite_url": invite_url,
+                                "expires_at_ms": expires_at_ms,
+                                "max_uses": max_uses,
+                            }))?
+                        );
+                    } else {
+                        println!("{invite_url}");
+                        println!("(Expires in 24 hours. Max uses: {max_uses})");
+                    }
+                }
+                _ => return Err(anyhow!("unexpected RPC result")),
+            }
+        }
+        ScopedCmd::Audit { json } => {
+            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::RoomAudit {
+                    room: room.to_string(),
+                }],
+            )
+            .await?;
+            match rpc_line_ok(&batch.results[0])? {
+                RpcResult::RoomAudit(resp) => {
+                    if json {
+                        println!("{}", serde_json::to_string_pretty(&resp)?);
+                    } else {
+                        println!("room {}", resp.room);
+                        if resp.grants.is_empty() {
+                            println!("(no grants recorded)");
+                        } else {
+                            let w_user = resp.grants.iter().map(|g| g.username.len()).max().unwrap_or(0);
+                            for g in &resp.grants {
+                                let caps = g.capabilities.join(", ");
+                                println!("{:<width$}  {}", g.username, caps, width = w_user.max(8));
+                            }
+                        }
+                    }
+                }
+                _ => return Err(anyhow!("unexpected RPC result")),
+            }
+        }
         ScopedCmd::Check { file, json } => {
             let mut text = String::new();
             match file {
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 995ce4a61d29b024c399c656134f541ecfd880cf..b45ba39419c84af8bf2333fc9b7d47e98525c45f 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -12,12 +12,61 @@ use tokio::sync::RwLock;
 
 use crate::{
     api::helpers::{api_error, now_ms, sha256_hex},
-    events::{Event, TokenIssued, UserRegistered},
+    events::{Event, GrantAdded, TokenIssued, UserRegistered},
     identity::{parse_agent, parse_username},
     html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},
     state::{AppState, PendingSession},
 };
 
+/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).
+const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join";
+
+async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> {
+    let now = now_ms();
+    let ga = {
+        let mut invites = state.invites.write().await;
+        let Some(inv) = invites.get_mut(invite_token) else {
+            return Err("invite not found".into());
+        };
+        if now > inv.expires_at_ms {
+            invites.remove(invite_token);
+            return Err("invite expired".into());
+        }
+        if inv.current_uses >= inv.max_uses {
+            return Err("invite exhausted".into());
+        }
+        inv.current_uses += 1;
+        Event::GrantAdded(GrantAdded {
+            ts: now,
+            room_id: inv.room_id.clone(),
+            username: grantee_username.to_string(),
+            capabilities: inv.capabilities.clone(),
+            granted_by: inv.inviter.clone(),
+        })
+    };
+
+    match state.event_log.append(&ga).await {
+        Ok(()) => {
+            let mut reduced = state.reduced.write().await;
+            reduced.apply_event(ga);
+            let mut invites = state.invites.write().await;
+            if let Some(inv) = invites.get(invite_token) {
+                if inv.current_uses >= inv.max_uses {
+                    invites.remove(invite_token);
+                }
+            }
+            Ok(())
+        }
+        Err(e) => {
+            let mut invites = state.invites.write().await;
+            if let Some(inv) = invites.get_mut(invite_token) {
+                inv.current_uses = inv.current_uses.saturating_sub(1);
+            }
+            Err(format!("{e}"))
+        }
+    }
+}
+
 fn pending_sessions(state: &AppState) -> Arc<RwLock<HashMap<String, PendingSession>>> {
     state.pending_sessions.clone()
 }
@@ -115,6 +164,42 @@ pub struct AuthLoginQuery {
     pub session: String,
 }
 
+pub async fn get_join_invite(Path(token): Path<String>, State(state): State<AppState>) -> impl IntoResponse {
+    let token = token.trim().to_string();
+    if token.is_empty() {
+        return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response();
+    }
+    let now = now_ms();
+    let valid = {
+        let invites = state.invites.read().await;
+        match invites.get(&token) {
+            None => false,
+            Some(inv) => now <= inv.expires_at_ms && inv.current_uses < inv.max_uses,
+        }
+    };
+    if !valid {
+        return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response();
+    }
+
+    let session = format!("p_{}", uuid::Uuid::new_v4().simple());
+    let s = PendingSession {
+        agent: INVITE_BROWSER_AGENT.to_string(),
+        created_ts: now_ms(),
+        provider: None,
+        provider_id: None,
+        redeem_invite: Some(token),
+        complete: None,
+    };
+    state.pending_sessions.write().await.insert(session.clone(), s);
+
+    let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
+    Redirect::temporary(&format!(
+        "{public_url}/auth/login?session={}",
+        urlencoding::encode(&session)
+    ))
+    .into_response()
+}
+
 pub async fn get_auth_login(Query(q): Query<AuthLoginQuery>, State(state): State<AppState>) -> impl IntoResponse {
     // Redirect to Google auth endpoint.
     let sessions = pending_sessions(&state);
@@ -205,6 +290,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
         s.provider = Som

… preview truncated; 45,403 characters omitted

download full diff A

B — c_11ce057e37af (tommy-mor)

message

[08565bea] Tokenize prose refs for garden URL links (#147)

* Tokenize prose refs for garden URL links

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

* Stop prose URLs at line boundaries

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

* Require braced DSL item bodies

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

---------

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

diff preview

diff --git a/server/src/dsl.rs b/server/src/dsl.rs
index 4203c2f59dd8825d7a91513c4023f3ccd37f1efc..b3c785674560a0b42a7f4c3aa13d80cd350f485b 100644
--- a/server/src/dsl.rs
+++ b/server/src/dsl.rs
@@ -1,7 +1,5 @@
 use std::collections::HashMap;
 
-use rand::Rng;
-
 /// Parsed DSL document.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct Document {
@@ -39,31 +37,57 @@ pub enum DslError {
 /// Matches the legacy Python parser behavior:
 /// - Supports toggle markers (open == close), e.g. ```...```
 /// - Supports nested markers (open != close), e.g. { ... { ... } ... }
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BlockKind {
+    CodeFence,
+    DoubleBrace,
+    Brace,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct MaskedBlock {
+    kind: BlockKind,
+}
+
 #[derive(Debug, Default, Clone)]
 pub struct BlockMasker {
     pub replacements: HashMap<String, String>,
+    blocks: HashMap<String, MaskedBlock>,
+    next_id: u32,
 }
 
 impl BlockMasker {
     pub fn new() -> Self {
         Self {
             replacements: HashMap::new(),
+            blocks: HashMap::new(),
+            next_id: 0,
         }
     }
 
-    fn new_token(&mut self) -> String {
-        let mut rng = rand::thread_rng();
-        let n: u32 = rng.gen();
-        let token = format!("__BLOCK_{:08x}__", n);
-        // Extremely unlikely collision; if it happens, regenerate.
-        if self.replacements.contains_key(&token) {
-            return self.new_token();
+    fn new_token(&mut self, haystack: &str) -> String {
+        loop {
+            let token = format!("__BLOCK_{:08x}__", self.next_id);
+            self.next_id = self.next_id.wrapping_add(1);
+            if !self.replacements.contains_key(&token) && !haystack.contains(&token) {
+                return token;
+            }
         }
-        token
     }
 
     /// Replace outermost balanced blocks with tokens.
     pub fn mask(&mut self, text: &str, open_marker: &str, close_marker: &str) -> String {
+        self.mask_kind(text, open_marker, close_marker, BlockKind::Brace)
+    }
+
+    /// Replace outermost balanced blocks with typed deterministic tokens.
+    pub fn mask_kind(
+        &mut self,
+        text: &str,
+        open_marker: &str,
+        close_marker: &str,
+        kind: BlockKind,
+    ) -> String {
         if text.is_empty() {
             return text.to_string();
         }
@@ -97,9 +121,10 @@ impl BlockMasker {
                     // Found end of outermost block
                     let s = start_idx.max(0) as usize;
                     let original_block = &text[s..i];
-                    let token = self.new_token();
+                    let token = self.new_token(text);
                     self.replacements
                         .insert(token.clone(), original_block.to_string());
+                    self.blocks.insert(token.clone(), MaskedBlock { kind });
                     result_parts.push(token);
                     current_idx = i;
                 }
@@ -176,13 +201,22 @@ impl BlockMasker {
         }
         token.to_string()
     }
+
+    pub fn block_kind(&self, token: &str) -> Option<BlockKind> {
+        self.blocks.get(token).map(|b| b.kind)
+    }
 }
 
 fn mask_all(mut masker: BlockMasker, text: &str) -> (BlockMasker, String) {
     // Mask hierarchy: Code -> Double Brace -> Single Brace.
-    let t = masker.mask(text, "```", "```");
-    let t = masker.mask(&t, "{{", "}}");
-    let t = masker.mask(&t, "{", "}");
+    let t = masker.mask_kind(text, "```", "```", BlockKind::CodeFence);
+    let t = masker.mask_kind(&t, "{{", "}}", BlockKind::DoubleBrace);
+    let t = masker.mask_kind(&t, "{", "}", BlockKind::Brace);
+    (masker, t)
+}
+
+fn mask_code_fences(mut masker: BlockMasker, text: &str) -> (BlockMasker, String) {
+    let t = masker.mask_kind(text, "```", "```", BlockKind::CodeFence);
     (masker, t)
 }
 
@@ -253,7 +287,34 @@ fn skip_ws(s: &str, mut i: usize) -> usize {
     i
 }
 
-fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> {
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ProseToken {
+    Text(String),
+    ItemRef(String),
+}
+
+fn trim_prose_item_ref_end(s: &str, mut end: usize) -> usize {
+    while end > 0 {
+        let Some((idx, c)) = s[..end].char_indices().next_back() else {
+            break;
+        };
+        if matches!(
+            c,
+            '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\''
+        ) {
+            end = idx;
+        } else {
+            break;
+        }
+    }
+    end
+}
+
+fn parse_item_name_at_with_mode(
+    s: &str,
+    i: usize,
+    trim_trailing_punctuation: bool,
+) -> Option<(String, usize)> {
     let bytes = s.as_bytes();
     if i >= bytes.len() {
         return None;
@@ -263,6 +324,9 @@ fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> {
     if s[i..].starts_with("https://") || s[i..].starts_with("http://") {
         let mut j = i;
         while j < bytes.len() {
+            if trim_trailing_punctuation && bytes[j] == b'\n' {
+                break;
+            }
             if bytes[j..].starts_with(b"__BLOCK_") || is_ws_byte(bytes[j]) {
                 break;
             }
@@ -271,6 +335,12 @@ fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> {
         if j <= i {
             return None;
         }
+        if trim_trailing_punctuation {
+            j = trim_prose_item_ref_end(s, j);
+            if j <= i {
+                return None;
+            }
+        }
         return Some((s[i..j].to_string(), j));
     }
 
@@ -296,6 +366,12 @@ fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> {
         if j <= i + 2 {
             return None;
         }
+        if trim_trailing_punctuation {
+            j = trim_prose_item_ref_end(s, j);
+            if j <= i + 2 {
+                return None;
+            }
+        }
         let raw = &s[i..j];
         if !is_item_name(raw) {
             return None;
@@ -336,6 +412,46 @@ fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> {
     Some((format!("~/{}", name), j))
 }
 
+fn parse_item_name_at(s: &str, i: usize) -> Option<(String, usize)> {
+    parse_item_name_at_with_mode(s, i, false)
+}
+
+pub fn parse_prose_item_ref_at(s: &str, i: usize) -> Option<(String, usize)> {
+    parse_item_name_at_with_mode(s, i, true)
+}
+
+pub fn tokenize_prose_item_refs(text: &str) -> Vec<ProseToken> {
+    if text.is_empty() {
+        return Vec::new();
+    }
+    let (masker, masked) = mask_code_fences(BlockMasker::new(), text);
+    let mut tokens = Vec::new();
+    let mut text_start = 0usize;
+    let mut i = 0usize;
+
+    while i < masked.len() {
+        if let Some((raw, end)) = parse_prose_item_ref_at(&masked, i) {
+            if text_start < i {
+                tokens.push(ProseToken::Text(masker.unmask(&masked[text_start..i])));
+            }
+            tokens.push(ProseToken::ItemRef(masker.unmask(&raw)));
+            i = end;
+            text_start = i;
+            continue;
+        }
+
+        let Some((_, c)) = masked[i..].char_indices().next() else {
+            break;
+        };
+        i += c.len_utf8();
+    }
+
+    if text_start < masked.len() {
+        tokens.push(ProseToken::Text(masker.unmask(&masked[text_start..])));
+    }
+    tokens
+}
+
 fn parse_block_token_at(s: &str, i: usize) -> Option<(String, usize)> {
     let bytes = s.as_bytes();
     if i >= bytes.len() {
@@ -401,6 +517,12 @@ fn parse_block_prefixed_statement(
     tail: &str,
     masker: &BlockMasker,
 ) -> Result<Stmt, DslError> {
+    if masker.block_kind(block_token) == Some(BlockKind::CodeFence) {
+        return Err(DslError::Parse(
+            "vote explanations must use `{ ... }`; code fences belong inside body blocks"
+                .to_string(),
+        ));
+    }
     // vote: block item_ref comparison item_ref
     let s = tail.trim_start();
     if s.is_empty() {
@@ -462,6 +584,11 @@ fn parse_item_definition_statement(stripped: &str, masker: &BlockMasker) -> Resu
     }
 
     if let Some((tok, end)) = parse_block_token_at(stripped, i) {
+        if masker.block_kind(&tok) == Some(BlockKind::CodeFence) {
+            return Err(DslError::Parse(
+                "item bodies must use `{ ... }`; code fences belong inside body blocks".to_string(),
+            ));
+        }
         let body = masker.extract_body(&tok);
         let tail = stripped[end..].trim();
         if !tail.is_empty() {
@@ -572,6 +699,10 @@ pub fn parse_full(text: &str) -> Result<Document, DslError> {
 
             if let Some((tok, end)) = parse_block_token_at(stripped, 0) {
                 if stripped[end..].trim().is_empty() {
+                    if masker.block_kind(&tok) == Some(BlockKind::CodeFence) {
+                        prose_buffer.push(line);
+                        continue;
+                    }
                     pending_block = Some(tok);
                     continue;
                 }
@@ -619,6 +750,70 @@ mod tests {
         assert_eq!(roundtrip, input);
     }
 
+    #[test]
+    fn blockmasker_tokens_are_deterministic_and_typed() {
+        let input = "x ```code``` y {body}";
+        let (masker, masked) = mask_all(BlockMasker::new(), input);
+        assert!(masked.contains("__BLOCK_00000000__"));
+        assert!(masked.contains("__BLOCK_00000001__"));
+        assert_eq!(
+            masker.block_kind("__BLOCK_00000000__"),
+            Some(BlockKind::CodeFence)
+        );
+        assert_eq!(
+            masker.block_kind("__BLOCK_00000001__"),
+            Some(BlockKind::Brace)
+        );
+        assert_eq!(masker.unmask(&masked), input);
+    }
+
+    #[test]
+    fn prose_tokenizer_finds_tilde_dash_and_raw_url_refs() {
+        let tokens =
+            tokenize_prose_item_refs("see ~/a/b then -/example.com/x and https://Example.com/A/B.");
+        assert_eq!(
+            tokens,
+            vec![
+                ProseToken::Text("see ".to_string()),
+                ProseToken::ItemRef("~/a/b".to_string()),
+                ProseToken::Text(" then ".to_string()),
+                ProseToken::ItemRef("-/example.com/x".to_string()),
+                ProseToken::Text(" and ".to_string()),
+                ProseToken::ItemRef("https://Example.com/A/B".to_string()),
+                ProseToken::Text(".".to_string()),
+            ]
+        );
+    }
+
+    #[test]
+    fn prose_tokenizer_stops_raw_urls_at_newlines() {
+        let tokens = tokenize_prose_item_refs("https://example.com/a/b.\n-/example.com/a/b");
+        assert_eq!(
+            tokens,
+            vec![
+                ProseToken::ItemRef("https://example.com/a/b".to_string()),
+                ProseToken::Text(".\n".to_string()),
+                ProseToken::ItemRef("-/example.com/a/b".to_string()),
+            ]
+        );
+    }
+
+    #[test]
+    fn prose_tokenizer_does_not_linkify_inside_code_fences() {
+        let tokens = tokenize_prose_item_refs(
+            "before ```json\n{\"url\":\"https://example.com\"}\n``` after ~/x",
+        );
+        assert_eq!(
+            tokens,
+            vec![
+                ProseToken::Text(
+                    "before ```json\n{\"url\":\"https://example.com\"}\n``` after ".to_string()
+                ),
+                ProseToken::ItemRef("~/x".to_string()),
+            ]
+        );
+    }
+
     #[test]
     fn parse_item_with_body_strips_outer_braces() {
         let input = "~/rust { Systems language }";
@@ -633,8 +828,8 @@ mod tests {
     }
 
     #[test]
-    fn parse_item_with_fenced_json_body_preserves_braces() {
-        let input = "~/item/in/url ```json\n{\"test\": true}\n```";
+    fn parse_item_with_braced_fenced_json_body_preserves_braces() {
+        let input = "~/item/in/url {\n```json\n{\"test\": true}\n```\n}";
         let doc = parse_full(input).unwrap();
         assert_eq!(
             doc.statements,
@@ -645,6 +840,51 @@ mod tests {
     

… preview truncated; 18,752 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.