{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[595b3850] Fix star-topology ranking by using degree-based d_max (#146).\n\nA pure forward star at the default `>` ratio (2:1) produced uniform 1/3\nscores, and the alphabetical-fallback sort placed the unambiguous winner\nlast. Root cause: `compute_scores_from_edges` divided by the max sum of\npairwise-normalized weights, so every node ended up with P_ii = 0 — a\nbipartite Markov chain whose power iteration oscillated and, after the\nconfigured even iteration count, returned to the uniform initial state.\n\nSwitch the divisor to the unweighted max neighbor degree, matching the\ncanonical Rank Centrality definition in Negahban–Oh–Shah 2012 §3.1\n(arXiv:1209.1688, eq. defP and the d_max definition in §6). This gives\nevery non-saturated node a positive self-loop, makes the chain aperiodic,\nand converges the star to π_zebra = 1/2, π_alpha = π_beta = 1/4.\n\nAdd Rust regression test and a Clojure test that drives the sorterc\nbinary against four .sorter fixtures (star, inverse star, chain, cycle).\n\nCo-Authored-By: Claude Opus 4.7 (1M context) \n\nSide A — unified diff (full patch):\ndiff --git a/server/src/ranking.rs b/server/src/ranking.rs\nindex 3710c9f64437f5bef3b2121905b6f3bcb7611047..38e6d09b4370e5f8cbae09c0e5760b4e7f1ef7db 100644\n--- a/server/src/ranking.rs\n+++ b/server/src/ranking.rs\n@@ -1,4 +1,4 @@\n-use std::collections::HashMap;\n+use std::collections::{HashMap, HashSet};\n \n use crate::path_types::ItemId;\n use crate::reducer::GroupState;\n@@ -143,23 +143,35 @@ fn compute_scores_from_edges(n: usize, edges: impl Iterator 0 for every non-maximum-degree node, and for max-degree\n+ // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous\n+ // loss). Without this, regular comparison graphs (e.g. a pure star at\n+ // ratio 2:1) produce a bipartite chain that oscillates instead of\n+ // converging — see issue #146.\n let mut out_edges: Vec> = vec![Vec::new(); n];\n- let mut out_deg: Vec = vec![0.0; n];\n+ let mut neighbors: Vec> = vec![HashSet::new(); n];\n \n for ((src, dst), w) in &normalized {\n out_edges[*src].push((*dst, *w));\n- out_deg[*src] += w;\n+ neighbors[*src].insert(*dst);\n+ neighbors[*dst].insert(*src);\n }\n \n- let mut max_out = 0.0f64;\n- for &d in &out_deg {\n- if d > max_out {\n- max_out = d;\n- }\n- }\n- if max_out <= 1e-12 {\n+ let weight_sum: Vec = out_edges\n+ .iter()\n+ .map(|es| es.iter().map(|(_, w)| *w).sum())\n+ .collect();\n+ let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);\n+ if d_max == 0 {\n return vec![1.0 / n as f64; n];\n }\n+ let d_max_f = d_max as f64;\n \n let mut scores = vec![1.0 / n as f64; n];\n let mut next = vec![0.0f64; n];\n@@ -167,14 +179,14 @@ fn compute_scores_from_edges(n: usize, edges: impl Iterator` ratio (2:1).\n+ /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the\n+ /// chain was bipartite; power iteration oscillated and returned the\n+ /// uniform initial distribution after an even number of steps. Using the\n+ /// paper's degree-based d_max gives every node a positive self-loop and\n+ /// the chain converges to the correct stationary distribution.\n+ #[test]\n+ fn star_topology_winner_at_top_via_subset() {\n+ let mut g = mk_group();\n+ g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1));\n+ g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1));\n+\n+ let mut items: Vec<(usize, String)> = g\n+ .idx_to_item\n+ .iter()\n+ .enumerate()\n+ .map(|(i, it)| (i, it.as_str().to_string()))\n+ .collect();\n+ items.sort_by(|a, b| a.1.cmp(&b.1));\n+ let idxs: Vec = items.iter().map(|(i, _)| *i).collect();\n+\n+ let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);\n+ for r in &ranked {\n+ eprintln!(\"{}: {}\", r.item.as_str(), r.score);\n+ }\n+ assert_eq!(\n+ ranked[0].item.as_str(),\n+ \"https://slug.social/zebra\",\n+ \"zebra won both votes and should rank #1\"\n+ );\n+ }\n+\n #[test]\n fn group_ranking_cache_dirty_flow() {\n let mut g = mk_group();\ndiff --git a/test/fixtures/ranking/chain.sorter b/test/fixtures/ranking/chain.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..912a96bdc08f631be27f3c9afc7e05004a2457c0\n--- /dev/null\n+++ b/test/fixtures/ranking/chain.sorter\n@@ -0,0 +1,10 @@\n+#t3\n+\n+~/t3/a { head of chain }\n+~/t3/b { middle }\n+~/t3/c { tail }\n+\n+{ a > b }\n+~/t3/a > ~/t3/b\n+{ b > c }\n+~/t3/b > ~/t3/c\ndiff --git a/test/fixtures/ranking/cycle.sorter b/test/fixtures/ranking/cycle.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..771731ae176e5d77e00ab89767ff2d7465bb7c6b\n--- /dev/null\n+++ b/test/fixtures/ranking/cycle.sorter\n@@ -0,0 +1,12 @@\n+#t4\n+\n+~/t4/a { node a }\n+~/t4/b { node b }\n+~/t4/c { node c }\n+\n+{ a > b }\n+~/t4/a > ~/t4/b\n+{ b > c }\n+~/t4/b > ~/t4/c\n+{ c > a }\n+~/t4/c > ~/t4/a\ndiff --git a/test/fixtures/ranking/star.sorter b/test/fixtures/ranking/star.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..135c9f9097d57b73c7ab18fac737ed3598d76fbe\n--- /dev/null\n+++ b/test/fixtures/ranking/star.sorter\n@@ -0,0 +1,11 @@\n+#repro\n+\n+~/repro/zebra { winner — beats both others }\n+~/repro/alpha { loser — alphabetically first }\n+~/repro/beta { loser — alphabetically middle }\n+\n+{ zebra beats alpha }\n+~/repro/zebra > ~/repro/alpha\n+\n+{ zebra beats beta }\n+~/repro/zebra > ~/repro/beta\ndiff --git a/test/fixtures/ranking/star_inverse.sorter b/test/fixtures/ranking/star_inverse.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..dab842fa924e5124865ee216b9565f6c7471c812\n--- /dev/null\n+++ b/test/fixtures/ranking/star_inverse.sorter\n@@ -0,0 +1,11 @@\n+#t2\n+\n+~/t2/win { source of incoming edges (loses both) }\n+~/t2/loss-a { winner }\n+~/t2/loss-b { winner }\n+\n+{ loss-a beats win }\n+~/t2/loss-a > ~/t2/win\n+\n+{ loss-b beats win }\n+~/t2/loss-b > ~/t2/win\ndiff --git a/test/ranking.clj b/test/ranking.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e1358783a84a264ee633bd79151ba29750b717cf\n--- /dev/null\n+++ b/test/ranking.clj\n@@ -0,0 +1,74 @@\n+(ns test.ranking\n+ \"Drives sorterc on .sorter fixtures and asserts ranking properties.\n+\n+ Regression coverage for issue #146 — pure forward star at default ratio\n+ (2:1 for `>`) used to produce tied uniform scores because the random walk\n+ on the normalized edge weights was bipartite. Fixed by switching to the\n+ degree-based d_max from Negahban–Oh–Shah rank centrality (§3.1).\"\n+ (:require [clojure.test :refer [deftest is testing]]\n+ [babashka.process :as p]\n+ [cheshire.core :as json]\n+ [clojure.java.io :as io]))\n+\n+(def sorterc-bin\n+ \"Path to the locally-built sorterc binary. Builds on demand if missing.\"\n+ (let [dbg \"target/debug/sorterc\"\n+ release \"target/release/sorterc\"]\n+ (cond\n+ (.exists (io/file release)) release\n+ (.exists (io/file dbg)) dbg\n+ :else\n+ (do (println \"building sorterc…\")\n+ (let [r (p/shell {:out :string :err :string :continue true}\n+ \"cargo build -p sorterc\")]\n+ (when-not (zero? (:exit r))\n+ (throw (ex-info \"cargo build -p sorterc failed\"\n+ {:stderr (:err r)}))))\n+ dbg))))\n+\n+(defn compile-sorter [fixture-path]\n+ (let [{:keys [out exit]} (p/shell {:out :string :err :string :continue true}\n+ sorterc-bin \"compile\" fixture-path)]\n+ (when-not (zero? exit)\n+ (throw (ex-info \"sorterc exit nonzero\" {:fixture fixture-path :out out})))\n+ (json/parse-string out true)))\n+\n+(defn first-component-ranking [result]\n+ (-> result :rankings first :components first :ranking))\n+\n+(defn item-leaf [item]\n+ (last (clojure.string/split item #\"/\")))\n+\n+(deftest chain-ranks-head-first\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/chain.sorter\"))\n+ names (mapv (comp item-leaf :item) ranking)]\n+ (is (= [\"a\" \"b\" \"c\"] names)\n+ \"chain a>b>c should rank a, b, c in order\")\n+ (is (apply > (map :score ranking))\n+ \"scores should attenuate strictly down the chain\")))\n+\n+(deftest inverse-star-puts-winners-on-top\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/star_inverse.sorter\"))\n+ names (mapv (comp item-leaf :item) ranking)]\n+ (is (= \"win\" (last names))\n+ \"the item that lost to both others should be ranked last\")))\n+\n+(deftest cycle-produces-uniform-scores\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/cycle.sorter\"))\n+ scores (map :score ranking)]\n+ (is (every? #(< (Math/abs (- % 1/3)) 1e-3) scores)\n+ \"a perfectly symmetric 3-cycle should give every node ~1/3\")))\n+\n+(deftest star-topology-winner-at-top\n+ ;; Issue #146 regression: source-only star at default `>` ratio (2:1).\n+ ;; Pre-fix produced uniform 1/3 scores; alphabetical fallback put the\n+ ;; unambiguous winner at the bottom. Post-fix the chain is aperiodic and\n+ ;; converges to π_zebra = 1/2, π_alpha = π_beta = 1/4.\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/star.sorter\"))\n+ names (mapv (comp item-leaf :item) ranking)\n+ by-name (into {} (map (juxt (comp item-leaf :item) :score) ranking))]\n+ (is (= \"zebra\" (first names))\n+ \"zebra won both votes and should rank #1\")\n+ (is (< (Math/abs (- (by-name \"zebra\") 0.5)) 1e-3))\n+ (is (< (Math/abs (- (by-name \"alpha\") 0.25)) 1e-3))\n+ (is (< (Math/abs (- (by-name \"beta\") 0.25)) 1e-3))))\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[23c8134e] Fix /-/ external garden index; resolvers/ + GitHub import cards (#150)\n\n* Fix external garden root listing; add resolvers/ with GitHub cards\n\nThe public and room external index pages queried children of a bogus\nhttps://./ parent, so /-/ always looked empty. Collect host-only https\nroots from all Web items and item_children edges so ghost parents from\nadd_child_edge appear.\n\nMove GitHub resolver into server/src/resolvers/ with default_external.rs\nand a try_render_resolver_item_body hook. Resolver ingests now store\nslug-github-card fenced JSON; render_item_body_in_scope shows a small\nGitHub article card (with legacy support for schema-less json fences on\ngithub.com URLs). Styling in theme_default.css; agents.md updated.\n\nCo-authored-by: tommy \n\n* Vote compare: GitHub cards in columns, layout CSS, tests\n\nPass item_bodies into vote_compare_item_card for linkified tooltips on\nnon-card bodies; clone item_bodies before dropping reducer read guard.\n\nAdd layout rules so rich cards sit in the grid corners (default + retro).\n\nUnit test on vote_compare_item_card; integration GET /vote/compare with\ningested slug-github-card bodies. agents.md clarifies compare columns.\n\nCo-authored-by: tommy \n\n---------\n\nCo-authored-by: Cursor Agent \n\nSide B — unified diff (full patch):\ndiff --git a/agents.md b/agents.md\nindex 7508234d9b04223d0e64cfe69fedbebd06a256b5..d8b801e454fdf37e7ac6038b91a69f83b0746d59 100644\n--- a/agents.md\n+++ b/agents.md\n@@ -42,7 +42,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma\n \n - **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`
    `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: \"/ui\"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.\n \n-- **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only.\n+- **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only. Implementation lives under **`server/src/resolvers/`** (GitHub resolver + import card JSON); ontology item pages and the **`GET /vote/compare`** left/right columns use **`render_item_body_in_scope`** in **`server/src/html/mod.rs`**, which calls **`server/src/resolvers/mod.rs::try_render_resolver_item_body`** before falling back to the usual **`
    `** linkified view.\n \n - **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.\n \ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 4b0214d18b173cd506d09176104f461dc4c4f208..c9eb8e242072e41fcf70da838bdf02dd4c838db8 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -18,7 +18,7 @@ use crate::{\n         rpc::{rpc_post_redact, rpc_post_with_bearer, rpc_room_delete},\n     },\n     canonical_path::canonicalize_tag,\n-    external_resolver::resolve_github_children,\n+    resolvers::resolve_github_children,\n     html::vote_compare_post_success_js,\n     html::{\n         external_resolver_status_markup, fragment_new_thread_slot, login_to_post_hint_markup,\ndiff --git a/server/src/external_resolver.rs b/server/src/external_resolver.rs\ndeleted file mode 100644\nindex a5812250fed7613950b5417f396f886a55fafccf..0000000000000000000000000000000000000000\n--- a/server/src/external_resolver.rs\n+++ /dev/null\n@@ -1,630 +0,0 @@\n-use async_trait::async_trait;\n-use serde_json::Value;\n-use tokio::sync::oneshot;\n-\n-use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd};\n-\n-const GITHUB_SYSTEM_PRINCIPAL: &str = \"system:github-resolver\";\n-const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000;\n-const GITHUB_MAX_PAGES: usize = 3;\n-\n-fn now_ms() -> i64 {\n-    use std::time::{SystemTime, UNIX_EPOCH};\n-    SystemTime::now()\n-        .duration_since(UNIX_EPOCH)\n-        .unwrap_or_default()\n-        .as_millis() as i64\n-}\n-\n-#[derive(Debug, Clone, PartialEq, Eq)]\n-pub struct ResolvedChild {\n-    pub url: String,\n-    pub title: String,\n-    pub body: Option,\n-}\n-\n-#[async_trait]\n-pub trait ExternalResolver: Send + Sync {\n-    /// e.g. `\"github.com\"`\n-    fn domain_match(&self) -> &'static str;\n-\n-    /// Normalizes URLs (e.g. stripping fragments); extend per-domain later.\n-    fn normalize(&self, path: &str) -> String;\n-\n-    /// Fetches body when missing; GitHub hook lands here in a follow-up.\n-    async fn fetch_body(&self, item: &ItemId) -> Result;\n-}\n-\n-#[derive(Clone)]\n-pub struct GitHubResolver {\n-    client: reqwest::Client,\n-    api_base_url: String,\n-    token: Option,\n-}\n-\n-impl GitHubResolver {\n-    pub fn from_env() -> Self {\n-        let api_base_url = std::env::var(\"SLUG_GITHUB_API_BASE_URL\")\n-            .ok()\n-            .filter(|s| !s.trim().is_empty())\n-            .unwrap_or_else(|| \"https://api.github.com\".to_string());\n-        let token = std::env::var(\"SLUG_GITHUB_TOKEN\")\n-            .ok()\n-            .filter(|s| !s.trim().is_empty());\n-        Self {\n-            client: reqwest::Client::new(),\n-            api_base_url: api_base_url.trim_end_matches('/').to_string(),\n-            token,\n-        }\n-    }\n-\n-    pub fn can_resolve_children(&self, item: &ItemId) -> bool {\n-        github_segments(item).is_some()\n-    }\n-\n-    pub async fn list_children(&self, item: &ItemId) -> Result, String> {\n-        let segments = github_segments(item).ok_or_else(|| \"not a GitHub URL\".to_string())?;\n-        match segments.as_slice() {\n-            [] => Ok(vec![]),\n-            [owner] => self.list_repos(owner).await,\n-            [owner, repo] => Ok(github_repo_sections(owner, repo)),\n-            [owner, repo, section] if section == \"issues\" => self.list_issues(owner, repo).await,\n-            [owner, repo, section] if section == \"pulls\" => self.list_pulls(owner, repo).await,\n-            [owner, repo, section] if section == \"commits\" => self.list_commits(owner, repo).await,\n-            [owner, repo, section] if section == \"releases\" => {\n-                self.list_releases(owner, repo).await\n-            }\n-            _ => Ok(vec![]),\n-        }\n-    }\n-\n-    async fn get_json(&self, path: &str) -> Result {\n-        let url = format!(\"{}/{}\", self.api_base_url, path.trim_start_matches('/'));\n-        let mut req = self\n-            .client\n-            .get(url)\n-            .header(reqwest::header::USER_AGENT, \"slugsocial-github-resolver\");\n-        if let Some(token) = &self.token {\n-            req = req.bearer_auth(token);\n-        }\n-        let resp = req\n-            .send()\n-            .await\n-            .map_err(|e| format!(\"GitHub request failed: {e}\"))?;\n-        let status = resp.status();\n-        if !status.is_success() {\n-            return Err(format!(\"GitHub request returned {status}\"));\n-        }\n-        resp.json::()\n-            .await\n-            .map_err(|e| format!(\"GitHub response JSON failed: {e}\"))\n-    }\n-\n-    async fn get_json_array_pages(&self, path: &str) -> Result, String> {\n-        let sep = if path.contains('?') { '&' } else { '?' };\n-        let mut out = Vec::new();\n-        for page in 1..=GITHUB_MAX_PAGES {\n-            let value = self.get_json(&format!(\"{path}{sep}page={page}\")).await?;\n-            let arr = value\n-                .as_array()\n-                .ok_or_else(|| \"GitHub paged response was not an array\".to_string())?;\n-            let n = arr.len();\n-            out.extend(arr.iter().cloned());\n-            if n < 100 {\n-                break;\n-            }\n-        }\n-        Ok(out)\n-    }\n-\n-    async fn list_repos(&self, owner: &str) -> Result, String> {\n-        let arr = self\n-            .get_json_array_pages(&format!(\n-                \"/users/{owner}/repos?per_page=100&sort=updated&type=owner\"\n-            ))\n-            .await?;\n-        let mut out = Vec::new();\n-        for repo in &arr {\n-            let name = repo\n-                .get(\"name\")\n-                .and_then(|v| v.as_str())\n-                .unwrap_or_default();\n-            if name.is_empty() {\n-                continue;\n-            }\n-            let full_name = repo\n-                .get(\"full_name\")\n-                .and_then(|v| v.as_str())\n-                .map(|s| s.to_ascii_lowercase())\n-                .unwrap_or_else(|| format!(\"{owner}/{name}\").to_ascii_lowercase());\n-            out.push(ResolvedChild {\n-                url: format!(\"https://github.com/{full_name}\"),\n-                title: full_name.clone(),\n-                body: Some(github_repo_body(repo)),\n-            });\n-        }\n-        out.sort_by(|a, b| a.url.cmp(&b.url));\n-        Ok(out)\n-    }\n-\n-    async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> {\n-        let arr = self\n-            .get_json_array_pages(&format!(\n-                \"/repos/{owner}/{repo}/issues?state=open&per_page=100\"\n-            ))\n-            .await?;\n-        let mut out = Vec::new();\n-        for issue in &arr {\n-            if issue.get(\"pull_request\").is_some() {\n-                continue;\n-            }\n-            let Some(number) = issue.get(\"number\").and_then(|v| v.as_i64()) else {\n-                continue;\n-            };\n-            let title = issue\n-                .get(\"title\")\n-                .and_then(|v| v.as_str())\n-                .unwrap_or(\"Untitled issue\");\n-            out.push(ResolvedChild {\n-                url: format!(\"https://github.com/{owner}/{repo}/issues/{number}\"),\n-                title: format!(\"#{number} {title}\"),\n-                body: Some(github_issue_body(issue, \"issue\")),\n-            });\n-        }\n-        out.sort_by(|a, b| a.url.cmp(&b.url));\n-        Ok(out)\n-    }\n-\n-    async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> {\n-        let arr = self\n-            .get_json_array_pages(&format!(\n-                \"/repos/{owner}/{repo}/pulls?state=open&per_page=100\"\n-            ))\n-            .await?;\n-        let mut out = Vec::new();\n-        for pull in &arr {\n-            let Some(number) = pull.get(\"number\").and_then(|v| v.as_i64()) else {\n-                continue;\n-            };\n-            let title = pull\n-                .get(\"title\")\n-                .and_then(|v| v.as_str())\n-                .unwrap_or(\"Untitled pull request\");\n-            out.push(ResolvedChild {\n-                url: format!(\"https://github.com/{owner}/{repo}/pulls/{number}\"),\n-                title: format!(\"#{number} {title}\"),\n-                body: Some(github_issue_body(pull, \"pull request\")),\n-            });\n-        }\n-        out.sort_by(|a, b| a.url.cmp(&b.url));\n-        Ok(out)\n-    }\n-\n-    async fn list_commits(&self, owner: &str, repo: &str) -> Result, String> {\n-        let arr = self\n-            .get_json_array_pages(&format!(\"/repos/{owner}/{repo}/commits?per_page=100\"))\n-            .await?;\n-        let mut out = Vec::new();\n-        for commit in &arr {\n-            let Some(sha) = github_string(commit, \"sha\") else {\n-                continue;\n-            };\n-            let short = sha.chars().take(7).collect::();\n-            let title = commit\n-                .get(\"commit\")\n-                .and_then(|c| c.get(\"message\"))\n-                .and_then(|v| v.as_str())\n-                .and_then(|m| m.lines().next())\n-                .filter(|s| !s.trim().is_empty())\n-                .unwrap_or(\"commit\");\n-            let url = github_string(commit, \"html_url\")\n-                .map(|s| s.to_string())\n-                .unwrap_or_else(|| format!(\"https://github.com/{owner}/{repo}/commit/{sha}\"));\n-            out.push(ResolvedChild {\n-                url,\n-                title: format!(\"{short} {title}\"),\n-                body: Some(github_commit_body(commit)),\n-            });\n-        }\n-        out.sort_by(|a, b| a.url.cmp(&b.url));\n-        Ok(out)\n-    }\n-\n-    async fn list_releases(&self, owner: &str, repo: &str) -> Result, String> {\n-        let arr = self\n-            .get_json_array_pages(&format!(\"/repos/{owner}/{repo}/releases?per_page=100\"))\n-            .await?;\n-        let mut out = Vec::new();\n-        for release in &arr {\n-            let Some(tag) = github_string(release, \"tag_name\") else {\n-                continue;\n-            };\n-            let title = github_string(release, \"name\").unwrap_or(tag);\n-            let url = github_string(release, \"html_url\")\n-                .map(|s| s.to_string())\n-                .unwrap_or_else(|| format!(\"https://github.com/{owner}/{repo}/releases/tag/{tag}\"));\n-            out.push(ResolvedChild {\n-                url,\n-                title: title.to_string(),\n-                body: Some(github_release_body(release)),\n-            });\n-        }\n-        out.sort_by(|a, b| a.url.cmp(&b.url));\n-        Ok(out)\n-    }\n-}\n-\n-fn github_segments(item: &ItemId) -> Option> {\n-    let url = url::Url::parse(item.as_str()).ok()?;\n-    if url.host_str()?.eq_ignore_ascii_case(\"github.com\") {\n-        Some(\n-            url.path_segments()\n-                .map(|segments| {\n-                    segments\n-                        .filter(|s| !s.is_empty())\n-                        .map(|s| s.to_ascii_lowercase())\n-                        .collect::>()\n-                })\n-                .unwrap_or_default(),\n-        )\n-    } else {\n-        None\n-    }\n-}\n-\n-fn github_repo_sections(owner: &str, repo: &str) -> Vec {\n-    [\n-        (\"issues\", \"GitHub issues for this repository.\"),\n-        (\"pulls\", \"GitHub pull requests for this repository.\"),\n-        (\"commits\", \"GitHub commits for this repository.\"),\n-        (\"releases\", \"GitHub releases for this repository.\"),\n-    ]\n-    .into_iter()\n-    .map(|(section, body)| ResolvedChild {\n-        url: format!(\"https://github.com/{owner}/{repo}/{section}\"),\n-        title: section.to_string(),\n-        body: Some(body.to_string()),\n-    })\n-    .collect()\n-}\n-\n-fn resolver_thread_tag(item: &ItemId) -> String {\n-    let tail = item\n-        .display_path()\n-        .trim_start_matches(\"-/\")\n-        .replace('/', \":\")\n-        .replace('?', \":\");\n-    format!(\"import:{tail}\")\n-}\n-\n-fn sanitize_body(s: &str) -> String {\n-    s.replace('{', \"(\")\n-        .replace('}', \")\")\n-        .replace(\"```\", \"` ` `\")\n-        .chars()\n-        .take(4_000)\n-        .collect()\n-}\n-\n-fn github_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> {\n-    value\n-        .get(key)\n-        .and_then(|v| v.as_str())\n-        .filter(|s| !s.trim().is_empty())\n-}\n-\n-fn github_user_login(value: &Value) -> Option<&str> {\n-    value\n-        .get(\"user\")\n-        .and_then(|u| u.get(\"login\"))\n-        .and_then(|v| v.as_str())\n-        .filter(|s| !s.trim().is_empty())\n-}\n-\n-fn github_labels(value: &Value) -> Vec {\n-    value\n-        .get(\"labels\")\n-        .and_then(|v| v.as_array())\n-        .into_iter()\n-        .flat_map(|labels| labels.iter())\n-        .filter_map(|label| label.get(\"name\").and_then(|v| v.as_str()))\n-        .filter(|name| !name.trim().is_empty())\n-        .map(|name| name.to_string())\n-        .collect()\n-}\n-\n-fn github_repo_body(repo: &Value) -> String {\n-    let full_name = github_string(repo, \"full_name\")\n-        .or_else(|| github_string(repo, \"name\"))\n-        .unwrap_or(\"GitHub repository\");\n-    let mut lines = vec![full_name.to_string()];\n-    if let Some(desc) = github_string(repo, \"description\") {\n-        lines.push(String::new());\n-        lines.push(desc.to_string());\n-    }\n-    if let Some(url) = github_string(repo, \"html_url\") {\n-        lines.push(String::new());\n-        lines.push(format!(\"Source: {url}\"));\n-    }\n-    if let Some(lang) = github_string(repo, \"language\") {\n-        lines.push(format!(\"Language: {lang}\"));\n-    }\n-    lines.join(\"\\n\")\n-}\n-\n-fn github_issue_body(issue: &Value, kind: &str) -> String {\n-    let number = issue\n-        .get(\"number\")\n-        .and_then(|v| v.as_i64())\n-        .map(|n| format!(\"#{n} \"))\n-        .unwrap_or_default();\n-    let title = github_string(issue, \"title\").unwrap_or(\"Untitled\");\n-    let state = github_string(issue, \"state\").unwrap_or(\"unknown\");\n-    let mut lines = vec![format!(\"{kind} {number}{title}\")];\n-    lines.push(format!(\"State: {state}\"));\n-    if let Some(author) = github_user_login(issue) {\n-        lines.push(format!(\"Author: @{author}\"));\n-    }\n-    let labels = github_labels(issue);\n-    if !labels.is_empty() {\n-        lines.push(format!(\"Labels: {}\", labels.join(\", \")));\n-    }\n-    if let Some(url) = github_string(issue, \"html_url\") {\n-        lines.push(format!(\"Source: {url}\"));\n-    }\n-    if let Some(body) = github_string(issue, \"body\") {\n-        lines.push(String::new());\n-        lines.push(body.to_string());\n-    }\n-    lines.join(\"\\n\")\n-}\n-\n-fn github_commit_body(commit: &Value) -> String {\n-    let sha = github_string(commit, \"sha\").unwrap_or(\"unknown\");\n-    let short = sha.chars().take(7).collect::();\n-    let commit_obj = commit.get(\"commit\");\n-    let message = commit_obj\n-        .and_then(|c| c.get(\"message\"))\n-        .and_then(|v| v.as_str())\n-        .unwrap_or(\"commit\");\n-    let mut lines = vec![format!(\"commit {short}\")];\n-    if let Some(author) = commit_obj\n-        .and_then(|c| c.get(\"author\"))\n-        .and_then(|a| a.get(\"name\"))\n-        .and_then(|v| v.as_str())\n-        .filter(|s| !s.trim().is_empty())\n-    {\n-        lines.push(format!(\"Author: {author}\"));\n-    }\n-    if let Some(login) = github_user_login(commit) {\n-        lines.push(format!(\"GitHub user: @{login}\"));\n-    }\n-    if let Some(date) = commit_obj\n-        .and_then(|c| c.get(\"author\"))\n-        .and_then(|a| a.get(\"date\"))\n-        .and_then(|v| v.as_str())\n-    {\n-        lines.push(format!(\"Date: {date}\"));\n-    }\n-    if let Some(url) = github_string(commit, \"html_url\") {\n-        lines.push(format!(\"Source: {url}\"));\n-    }\n-    lines.push(String::new());\n-    lines.push(message.to_string());\n-    lines.join(\"\\n\")\n-}\n-\n-fn github_release_body(release: &Value) -> String {\n-    let tag = github_string(release, \"tag_name\").unwrap_or(\"untagged\");\n-    let title = github_string(release, \"name\").unwrap_or(tag);\n-    let mut lines = vec![format!(\"release {title}\")];\n-    lines.push(format!(\"Tag: {tag}\"));\n-    if release\n-        .get(\"draft\")\n-        .and_then(|v| v.as_bool())\n-        .unwrap_or(false)\n-    {\n-        lines.push(\"Draft: yes\".to_string());\n-    }\n-    if release\n-        .get(\"prerelease\")\n-        .and_then(|v| v.as_bool())\n-        .unwrap_or(false)\n-    {\n-        lines.push(\"Prerelease: yes\".to_string());\n-    }\n-    if let Some(author) = github_user_login(release) {\n-        lines.push(format!(\"Author: @{author}\"));\n-    }\n-    if let Some(published) = github_string(release, \"published_at\") {\n-        lines.push(format!(\"Published: {published}\"));\n-    }\n-    if let Some(url) = github_string(release, \"html_url\") {\n-        lines.push(format!(\"Source: {url}\"));\n-    }\n-    if let Some(body) = github_string(release, \"body\") {\n-        lines.push(String::new());\n-        lines.push(body.to_string());\n-    }\n-    lines.join(\"\\n\")\n-}\n-\n-fn children_to_dsl(children: &[ResolvedChild]) -> String {\n-    let mut out = String::new();\n-    for child in children {\n-        let body = child\n-            .body\n-            .as_deref()\n-            .filter(|s| !s.trim().is_empty())\n-            .unwrap_or(child.title.as_str());\n-        if body.trim_start().starts_with(\"```\") {\n-            out.push_str(&format!(\"{} {{\\n{}\\n}}\\n\\n\", child.url, body.trim()));\n-        } else {\n-            out.push_str(&format!(\n-                \"{} {{\\n{}\\n}}\\n\\n\",\n-                child.url,\n-                sanitize_body(body)\n-            ));\n-        }\n-    }\n-    out\n-}\n-\n-pub async fn resolve_github_children(\n-    state: &AppState,\n-    room: &str,\n-    item: &ItemId,\n-) -> Result {\n-    if !state.github_resolver.can_resolve_children(item) {\n-        return Err(\"no GitHub resolver for this item\".to_string());\n-    }\n-\n-    let key = format!(\"github:{}:{}\", room.trim(), item.as_str());\n-    let now = now_ms();\n-    {\n-        let mut runs = state.resolver_runs.write().await;\n-        if let Some(last) = runs.get(&key) {\n-            let remaining = GITHUB_RESOLVER_COOLDOWN_MS - (now - *last);\n-            if remaining > 0 {\n-                return Err(format!(\n-                    \"GitHub resolver cooldown: try again in {}s\",\n-                    (remaining + 999) / 1000\n-                ));\n-            }\n-        }\n-        runs.insert(key, now);\n-    }\n-\n-    let children = state.github_resolver.list_children(item).await?;\n-    if children.is_empty() {\n-        return Ok(0);\n-    }\n-    let text = children_to_dsl(&children);\n-    let thread_tag = resolver_thread_tag(item);\n-    let (tx, rx) = oneshot::channel();\n-    state\n-        .write_tx\n-        .send(WriteCmd::SystemIngest {\n-            room: room.to_string(),\n-            thread_tag,\n-            text,\n-            principal: GITHUB_SYSTEM_PRINCIPAL.to_string(),\n-            reply: tx,\n-        })\n-        .await\n-        .map_err(|_| \"writer unavailable\".to_string())?;\n-    rx.await\n-        .map_err(|_| \"writer dropped\".to_string())?\n-        .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!(\"{msg}: {h}\")))?;\n-    Ok(children.len())\n-}\n-\n-/// Placeholder until other domain-specific resolvers exist.\n-pub struct DefaultExternalResolver;\n-\n-#[async_trait]\n-impl ExternalResolver for DefaultExternalResolver {\n-    fn domain_match(&self) -> &'static str {\n-        \"\"\n-    }\n-\n-    fn normalize(&self, path: &str) -> String {\n-        path.to_string()\n-    }\n-\n-    async fn fetch_body(&self, _item: &ItemId) -> Result {\n-        Err(\"external fetch not implemented\".to_string())\n-    }\n-}\n-\n-#[cfg(test)]\n-mod tests {\n-    use super::*;\n-\n-    #[test]\n-    fn github_segments_parse_normalized_url() {\n-        let item = ItemId::parse(\"https://github.com/Sortersocial/Slug/issues\").unwrap();\n-        assert_eq!(\n-            github_segments(&item),\n-            Some(vec![\n-                \"sortersocial\".to_string(),\n-                \"slug\".to_string(),\n-                \"issues\".to_string()\n-            ])\n-        );\n-    }\n-\n-    #[test]\n-    fn repo_sections_are_direct_children() {\n-        let sections = github_repo_sections(\"sortersocial\", \"slug\");\n-        let urls: Vec = sections.into_iter().map(|c| c.url).collect();\n-        assert!(urls.contains(&\"https://github.com/sortersocial/slug/issues\".to_string()));\n-        assert!(urls.contains(&\"https://github.com/sortersocial/slug/pulls\".to_string()));\n-    }\n-\n-    #[test]\n-    fn children_to_dsl_contains_item_bodies() {\n-        let dsl = children_to_dsl(&[ResolvedChild {\n-            url: \"https://github.com/o/r/issues/1\".into(),\n-            title: \"#1 title\".into(),\n-            body: Some(\"body with {braces}\".into()),\n-        }]);\n-        assert!(dsl.contains(\"https://github.com/o/r/issues/1\"));\n-        assert!(dsl.contains(\"body with (braces)\"));\n-    }\n-\n-    #[test]\n-    fn children_to_dsl_preserves_fenced_json_bodies() {\n-        let dsl = children_to_dsl(&[ResolvedChild {\n-            url: \"https://github.com/o/r/issues/1\".into(),\n-            title: \"#1 title\".into(),\n-            body: Some(\"```json\\n{\\\"test\\\": true}\\n```\".into()),\n-        }]);\n-        assert!(dsl.contains(\"https://github.com/o/r/issues/1 {\\n```json\"));\n-        assert!(dsl.contains(\"{\\\"test\\\": true}\"));\n-        assert!(dsl.contains(\"```\\n}\\n\"));\n-    }\n-\n-    #[test]\n-    fn github_issue_body_is_readable_text_not_json_dump() {\n-        let issue = serde_json::json!({\n-            \"number\": 12,\n-            \"title\": \"Render children\",\n-            \"state\": \"open\",\n-            \"html_url\": \"https://github.com/o/r/issues/12\",\n-            \"user\": {\"login\": \"octo\"},\n-            \"labels\": [{\"name\": \"bug\"}],\n-            \"body\": \"The issue body.\"\n-        });\n-        let body = github_issue_body(&issue, \"issue\");\n-        assert!(body.contains(\"issue #12 Render children\"));\n-        assert!(body.contains(\"Author: @octo\"));\n-        assert!(body.contains(\"The issue body.\"));\n-        assert!(!body.trim_start().starts_with(\"```json\"));\n-    }\n-\n-    #[test]\n-    fn github_commit_and_release_bodies_are_readable() {\n-        let commit = serde_json::json!({\n-            \"sha\": \"abcdef123456\",\n-            \"html_url\": \"https://github.com/o/r/commit/abcdef123456\",\n-            \"author\": {\"login\": \"octo\"},\n-            \"commit\": {\n-                \"message\": \"Fix vote page\\n\\nDetails here.\",\n-                \"author\": {\"name\": \"Octo Dev\", \"date\": \"2026-05-17T00:00:00Z\"}\n-            }\n-        });\n-        let release = serde_json::json!({\n-            \"tag_name\": \"v1.2.3\",\n-            \"name\": \"Release 1.2.3\",\n-            \"html_url\": \"https://github.com/o/r/releases/tag/v1.2.3\",\n-            \"author\": {\"login\": \"octo\"},\n-            \"prerelease\": true,\n-            \"body\": \"Release notes.\"\n-        });\n-        assert!(github_commit_body(&commit).contains(\"commit abcdef1\"));\n-        assert!(github_commit_body(&commit).contains(\"Fix vote page\"));\n-        assert!(github_release_body(&release).contains(\"release Release 1.2.3\"));\n-        assert!(github_release_body(&release).contains(\"Prerelease: yes\"));\n-    }\n-}\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex 9ca66e7c5860d428e95abf5df518fc1e7b4f6332..e2dc6e5529d4a3126723d0dea75931d0738b6c83 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -7,7 +7,7 @@ use axum_extra::extract::cookie::CookieJar;\n use maud::html;\n use serde::Deserialize;\n use serde_json::json;\n-use std::collections::HashSet;\n+use std::collections::{HashMap, HashSet};\n \n use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE, Engine as _};\n \n@@ -21,8 +21,8 @@ use crate::{\n     path_types::ItemId,\n     reducer::{ContentState, ReducerState, ScopeId},\n     scope_rank::{\n-        build_children_rankings, build_rankings_for_item_set, resolve_scope_recursive,\n-        suggest_next_pair_in_pool, ChildrenRankings,\n+        build_children_rankings, build_rankings_for_item_set, external_root_host_items,\n+        resolve_scope_recursive, suggest_next_pair_in_pool, ChildrenRankings,\n     },\n     state::AppState,\n     timeago,\n@@ -33,7 +33,7 @@ use super::{\n     breadcrumb_path::{ExternalOntologyPath, OntologyPath},\n     cli_panel,\n     forum::ThreadNav,\n-    layout, layout_full_bleed_chromeless, now_ms, ratio_pct, render_linkified_with_embeds_in_scope,\n+    layout, layout_full_bleed_chromeless, now_ms, ratio_pct, render_item_body_in_scope,\n     theme_from_jar, theme_next_from_uri,\n };\n \n@@ -358,6 +358,7 @@ fn vote_compare_item_card(\n     item: &ItemId,\n     body: Option<&String>,\n     side_class: &str,\n+    item_bodies: Option<&HashMap>,\n ) -> maud::Markup {\n     html! {\n         div class=(format!(\"vote-compare-side {side_class}\")) {\n@@ -366,10 +367,10 @@ fn vote_compare_item_card(\n             }\n             @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {\n                 div class=\"vote-compare-item-body\" {\n-                    (render_linkified_with_embeds_in_scope(\n+                    (render_item_body_in_scope(\n                         body,\n                         nav.garden_root_url(),\n-                        None,\n+                        item_bodies,\n                     ))\n                 }\n             } @else {\n@@ -678,10 +679,11 @@ pub async fn external_garden_index(\n ) -> impl IntoResponse {\n     let nav = ThreadNav::public();\n     let ext_path = ExternalOntologyPath::from_input(\"\");\n-    let parent = ItemId::parse(\"https://.\").unwrap();\n     let child_rankings = {\n         let reduced = state.reduced.read().await;\n-        build_children_rankings(reduced.public(), &parent)\n+        let content = reduced.public();\n+        let hosts = external_root_host_items(content);\n+        build_rankings_for_item_set(content, &hosts)\n     };\n \n     let url_key = canonical_view_url(&uri);\n@@ -812,9 +814,11 @@ pub async fn room_external_garden_index(\n         return room_not_found_page(&jar, &uri).into_response();\n     }\n     let ext_path = ExternalOntologyPath::from_input(\"\");\n-    let parent = ItemId::parse(\"https://.\").unwrap();\n-    let child_rankings =\n-        build_children_rankings(content_for_garden_view(&reduced, &nav.scope()), &parent);\n+    let child_rankings = {\n+        let content = content_for_garden_view(&reduced, &nav.scope());\n+        let hosts = external_root_host_items(content);\n+        build_rankings_for_item_set(content, &hosts)\n+    };\n     drop(reduced);\n \n     let url_key = canonical_view_url(&uri);\n@@ -1334,7 +1338,7 @@ async fn render_scope_view(\n                 }\n                 @if let Some(body) = &model.body {\n                     div class=\"ont-item-content\" {\n-                        (render_linkified_with_embeds_in_scope(\n+                        (render_item_body_in_scope(\n                             body,\n                             nav.garden_root_url(),\n                             Some(&scope_content.item_bodies),\n@@ -1592,6 +1596,7 @@ async fn vote_compare_inner(\n     let edge_history = vote_edge_history_markup(content, &left, &right);\n     let left_body = content.item_bodies.get(&left).cloned();\n     let right_body = content.item_bodies.get(&right).cloned();\n+    let item_bodies_for_cards = content.item_bodies.clone();\n     let next_pair = suggest_next_vote_pair(content, &left, &right);\n     drop(reduced);\n \n@@ -1623,9 +1628,21 @@ async fn vote_compare_inner(\n     section class=\"vote-compare-shell\" {\n         h2 { \"compare\" }\n         div class=\"vote-compare-pair\" {\n-            (vote_compare_item_card(&nav, &left, left_body.as_ref(), \"vote-compare-left\"))\n+            (vote_compare_item_card(\n+                &nav,\n+                &left,\n+                left_body.as_ref(),\n+                \"vote-compare-left\",\n+                Some(&item_bodies_for_cards),\n+            ))\n             span class=\"vote-compare-vs\" { \"vs\" }\n-            (vote_compare_item_card(&nav, &right, right_body.as_ref(), \"vote-compare-right\"))\n+            (vote_compare_item_card(\n+                &nav,\n+                &right,\n+                right_body.as_ref(),\n+                \"vote-compare-right\",\n+                Some(&item_bodies_for_cards),\n+            ))\n         }\n         (vote_compare_nav_markup(&nav, next_pair.as_ref(), &left, &right, q.thread.as_deref()))\n         div id=\"vote-edge-history-region\" {\n@@ -2020,6 +2037,40 @@ mod tests {\n         assert!(items.contains(\"https://slug.social/~/topic/b\"));\n     }\n \n+    #[test]\n+    fn vote_compare_item_card_renders_github_import_markup() {\n+        use crate::html::forum::ThreadNav;\n+        use super::vote_compare_item_card;\n+        use crate::path_types::ItemId;\n+\n+        let nav = ThreadNav::public();\n+        let item = ItemId::parse(\"https://github.com/o/r/issues/1\").unwrap();\n+        let json = serde_json::json!({\n+            \"v\": 1,\n+            \"schema\": \"slug_github_import\",\n+            \"kind\": \"issue\",\n+            \"url\": \"https://github.com/o/r/issues/1\",\n+            \"headline\": \"#1 Compare card\",\n+            \"sublines\": [\"State: open\"],\n+        });\n+        let body = format!(\"```slug-github-card\\n{}\\n```\", json.to_string());\n+        let html = vote_compare_item_card(\n+            &nav,\n+            &item,\n+            Some(&body),\n+            \"vote-compare-left\",\n+            None,\n+        )\n+        .into_string();\n+        assert!(\n+            html.contains(\"github-import-card\"),\n+            \"expected rich GitHub card markup, got: {html}\"\n+        );\n+        assert!(html.contains(\"item-body-rich\"));\n+        assert!(html.contains(\"vote-compare-left\"));\n+        assert!(html.contains(\"#1 Compare card\"));\n+    }\n+\n     #[test]\n     fn external_source_href_maps_youtube_path_identity_back_to_watch_url() {\n         assert_eq!(\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex a1b929625acbd5298c0cf62f3ca0892079edcb2a..3b23e19a8b35aa4c0b0480e29de7d46ad57ab276 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -793,6 +793,20 @@ pub(super) fn render_linkified_with_embeds_in_scope(\n     }\n }\n \n+/// Item page / thread body: resolver-specific rich HTML, else linkified `
    ` + media embeds.\n+pub(super) fn render_item_body_in_scope(\n+    raw: &str,\n+    garden_prefix: &str,\n+    item_bodies: Option<&HashMap>,\n+) -> Markup {\n+    if let Some(m) = crate::resolvers::try_render_resolver_item_body(raw) {\n+        return html! {\n+            div class=\"item-body-rich\" { (m) }\n+        };\n+    }\n+    render_linkified_with_embeds_in_scope(raw, garden_prefix, item_bodies)\n+}\n+\n /// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.\n fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {\n     assert!(\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 84e94bbec144eae77de68482385941cd2c5845eb..c1d477d21aea03aff00e6f0689b0b4379d0d68d2 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -5,7 +5,7 @@ pub mod canonical_path;\n pub mod dsl;\n pub mod event_log;\n pub mod events;\n-pub mod external_resolver;\n+pub mod resolvers;\n pub mod form_template;\n pub mod html;\n pub mod identity;\n@@ -51,7 +51,7 @@ pub fn create_app_state(cfg: AppConfig) -> AppState {\n         write_tx,\n         views,\n         resolver_runs: Arc::new(RwLock::new(HashMap::new())),\n-        github_resolver: Arc::new(crate::external_resolver::GitHubResolver::from_env()),\n+        github_resolver: Arc::new(crate::resolvers::GitHubResolver::from_env()),\n     };\n     tokio::spawn(crate::api::write_actor::writer_actor(\n         write_rx,\ndiff --git a/server/src/resolvers/default_external.rs b/server/src/resolvers/default_external.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d37c222abcee3c20b22189b2822da9e9a6ff0515\n--- /dev/null\n+++ b/server/src/resolvers/default_external.rs\n@@ -0,0 +1,22 @@\n+use async_trait::async_trait;\n+\n+use crate::path_types::ItemId;\n+use super::github::ExternalResolver;\n+\n+/// Placeholder until other domain-specific resolvers exist.\n+pub struct DefaultExternalResolver;\n+\n+#[async_trait]\n+impl ExternalResolver for DefaultExternalResolver {\n+    fn domain_match(&self) -> &'static str {\n+        \"\"\n+    }\n+\n+    fn normalize(&self, path: &str) -> String {\n+        path.to_string()\n+    }\n+\n+    async fn fetch_body(&self, _item: &ItemId) -> Result {\n+        Err(\"external fetch not implemented\".to_string())\n+    }\n+}\ndiff --git a/server/src/resolvers/github.rs b/server/src/resolvers/github.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..5a9c0c38ff01ca5894f7dc62c1008371dacb0cf1\n--- /dev/null\n+++ b/server/src/resolvers/github.rs\n@@ -0,0 +1,755 @@\n+use async_trait::async_trait;\n+use maud::html;\n+use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n+use tokio::sync::oneshot;\n+\n+use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd};\n+\n+pub const SLUG_GITHUB_SCHEMA: &str = \"slug_github_import\";\n+\n+const GITHUB_SYSTEM_PRINCIPAL: &str = \"system:github-resolver\";\n+const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000;\n+const GITHUB_MAX_PAGES: usize = 3;\n+\n+fn now_ms() -> i64 {\n+    use std::time::{SystemTime, UNIX_EPOCH};\n+    SystemTime::now()\n+        .duration_since(UNIX_EPOCH)\n+        .unwrap_or_default()\n+        .as_millis() as i64\n+}\n+\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+#[serde(rename_all = \"snake_case\")]\n+pub enum GithubImportKind {\n+    Repo,\n+    RepoSection,\n+    Issue,\n+    Pull,\n+    Commit,\n+    Release,\n+}\n+\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+pub struct GithubImportCard {\n+    pub v: u32,\n+    #[serde(default)]\n+    pub schema: String,\n+    pub kind: GithubImportKind,\n+    pub url: String,\n+    pub headline: String,\n+    #[serde(default)]\n+    pub sublines: Vec,\n+    #[serde(default)]\n+    pub excerpt: Option,\n+}\n+\n+impl GithubImportCard {\n+    fn new(kind: GithubImportKind, url: String, headline: String) -> Self {\n+        Self {\n+            v: 1,\n+            schema: SLUG_GITHUB_SCHEMA.to_string(),\n+            kind,\n+            url,\n+            headline,\n+            sublines: Vec::new(),\n+            excerpt: None,\n+        }\n+    }\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Eq)]\n+pub struct ResolvedChild {\n+    pub url: String,\n+    pub title: String,\n+    pub card: GithubImportCard,\n+}\n+\n+#[async_trait]\n+pub trait ExternalResolver: Send + Sync {\n+    /// e.g. `\"github.com\"`\n+    fn domain_match(&self) -> &'static str;\n+\n+    /// Normalizes URLs (e.g. stripping fragments); extend per-domain later.\n+    fn normalize(&self, path: &str) -> String;\n+\n+    /// Fetches body when missing; GitHub hook lands here in a follow-up.\n+    async fn fetch_body(&self, item: &ItemId) -> Result;\n+}\n+\n+#[derive(Clone)]\n+pub struct GitHubResolver {\n+    client: reqwest::Client,\n+    api_base_url: String,\n+    token: Option,\n+}\n+\n+impl GitHubResolver {\n+    pub fn from_env() -> Self {\n+        let api_base_url = std::env::var(\"SLUG_GITHUB_API_BASE_URL\")\n+            .ok()\n+            .filter(|s| !s.trim().is_empty())\n+            .unwrap_or_else(|| \"https://api.github.com\".to_string());\n+        let token = std::env::var(\"SLUG_GITHUB_TOKEN\")\n+            .ok()\n+            .filter(|s| !s.trim().is_empty());\n+        Self {\n+            client: reqwest::Client::new(),\n+            api_base_url: api_base_url.trim_end_matches('/').to_string(),\n+            token,\n+        }\n+    }\n+\n+    pub fn can_resolve_children(&self, item: &ItemId) -> bool {\n+        github_segments(item).is_some()\n+    }\n+\n+    pub async fn list_children(&self, item: &ItemId) -> Result, String> {\n+        let segments = github_segments(item).ok_or_else(|| \"not a GitHub URL\".to_string())?;\n+        match segments.as_slice() {\n+            [] => Ok(vec![]),\n+            [owner] => self.list_repos(owner).await,\n+            [owner, repo] => Ok(github_repo_sections(owner, repo)),\n+            [owner, repo, section] if section == \"issues\" => self.list_issues(owner, repo).await,\n+            [owner, repo, section] if section == \"pulls\" => self.list_pulls(owner, repo).await,\n+            [owner, repo, section] if section == \"commits\" => self.list_commits(owner, repo).await,\n+            [owner, repo, section] if section == \"releases\" => {\n+                self.list_releases(owner, repo).await\n+            }\n+            _ => Ok(vec![]),\n+        }\n+    }\n+\n+    async fn get_json(&self, path: &str) -> Result {\n+        let url = format!(\"{}/{}\", self.api_base_url, path.trim_start_matches('/'));\n+        let mut req = self\n+            .client\n+            .get(url)\n+            .header(reqwest::header::USER_AGENT, \"slugsocial-github-resolver\");\n+        if let Some(token) = &self.token {\n+            req = req.bearer_auth(token);\n+        }\n+        let resp = req\n+            .send()\n+            .await\n+            .map_err(|e| format!(\"GitHub request failed: {e}\"))?;\n+        let status = resp.status();\n+        if !status.is_success() {\n+            return Err(format!(\"GitHub request returned {status}\"));\n+        }\n+        resp.json::()\n+            .await\n+            .map_err(|e| format!(\"GitHub response JSON failed: {e}\"))\n+    }\n+\n+    async fn get_json_array_pages(&self, path: &str) -> Result, String> {\n+        let sep = if path.contains('?') { '&' } else { '?' };\n+        let mut out = Vec::new();\n+        for page in 1..=GITHUB_MAX_PAGES {\n+            let value = self.get_json(&format!(\"{path}{sep}page={page}\")).await?;\n+            let arr = value\n+                .as_array()\n+                .ok_or_else(|| \"GitHub paged response was not an array\".to_string())?;\n+            let n = arr.len();\n+            out.extend(arr.iter().cloned());\n+            if n < 100 {\n+                break;\n+            }\n+        }\n+        Ok(out)\n+    }\n+\n+    async fn list_repos(&self, owner: &str) -> Result, String> {\n+        let arr = self\n+            .get_json_array_pages(&format!(\n+                \"/users/{owner}/repos?per_page=100&sort=updated&type=owner\"\n+            ))\n+            .await?;\n+        let mut out = Vec::new();\n+        for repo in &arr {\n+            let name = repo\n+                .get(\"name\")\n+                .and_then(|v| v.as_str())\n+                .unwrap_or_default();\n+            if name.is_empty() {\n+                continue;\n+            }\n+            let full_name = repo\n+                .get(\"full_name\")\n+                .and_then(|v| v.as_str())\n+                .map(|s| s.to_ascii_lowercase())\n+                .unwrap_or_else(|| format!(\"{owner}/{name}\").to_ascii_lowercase());\n+            let url = format!(\"https://github.com/{full_name}\");\n+            let mut card = card_for_repo(repo, &url);\n+            card.headline = full_name.clone();\n+            out.push(ResolvedChild {\n+                url,\n+                title: full_name,\n+                card,\n+            });\n+        }\n+        out.sort_by(|a, b| a.url.cmp(&b.url));\n+        Ok(out)\n+    }\n+\n+    async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> {\n+        let arr = self\n+            .get_json_array_pages(&format!(\n+                \"/repos/{owner}/{repo}/issues?state=open&per_page=100\"\n+            ))\n+            .await?;\n+        let mut out = Vec::new();\n+        for issue in &arr {\n+            if issue.get(\"pull_request\").is_some() {\n+                continue;\n+            }\n+            let Some(number) = issue.get(\"number\").and_then(|v| v.as_i64()) else {\n+                continue;\n+            };\n+            let title = issue\n+                .get(\"title\")\n+                .and_then(|v| v.as_str())\n+                .unwrap_or(\"Untitled issue\");\n+            let url = format!(\"https://github.com/{owner}/{repo}/issues/{number}\");\n+            let card = card_for_issue(issue, &url, GithubImportKind::Issue);\n+            out.push(ResolvedChild {\n+                url: url.clone(),\n+                title: format!(\"#{number} {title}\"),\n+                card,\n+            });\n+        }\n+        out.sort_by(|a, b| a.url.cmp(&b.url));\n+        Ok(out)\n+    }\n+\n+    async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> {\n+        let arr = self\n+            .get_json_array_pages(&format!(\n+                \"/repos/{owner}/{repo}/pulls?state=open&per_page=100\"\n+            ))\n+            .await?;\n+        let mut out = Vec::new();\n+        for pull in &arr {\n+            let Some(number) = pull.get(\"number\").and_then(|v| v.as_i64()) else {\n+                continue;\n+            };\n+            let title = pull\n+                .get(\"title\")\n+                .and_then(|v| v.as_str())\n+                .unwrap_or(\"Untitled pull request\");\n+            let url = format!(\"https://github.com/{owner}/{repo}/pulls/{number}\");\n+            let card = card_for_issue(pull, &url, GithubImportKind::Pull);\n+            out.push(ResolvedChild {\n+                url: url.clone(),\n+                title: format!(\"#{number} {title}\"),\n+                card,\n+            });\n+        }\n+        out.sort_by(|a, b| a.url.cmp(&b.url));\n+        Ok(out)\n+    }\n+\n+    async fn list_commits(&self, owner: &str, repo: &str) -> Result, String> {\n+        let arr = self\n+            .get_json_array_pages(&format!(\"/repos/{owner}/{repo}/commits?per_page=100\"))\n+            .await?;\n+        let mut out = Vec::new();\n+        for commit in &arr {\n+            let Some(sha) = github_string(commit, \"sha\") else {\n+                continue;\n+            };\n+            let short = sha.chars().take(7).collect::();\n+            let title = commit\n+                .get(\"commit\")\n+                .and_then(|c| c.get(\"message\"))\n+                .and_then(|v| v.as_str())\n+                .and_then(|m| m.lines().next())\n+                .filter(|s| !s.trim().is_empty())\n+                .unwrap_or(\"commit\");\n+            let url = github_string(commit, \"html_url\")\n+                .map(|s| s.to_string())\n+                .unwrap_or_else(|| format!(\"https://github.com/{owner}/{repo}/commit/{sha}\"));\n+            let card = card_for_commit(commit, &url, &short, title);\n+            out.push(ResolvedChild {\n+                url: url.clone(),\n+                title: format!(\"{short} {title}\"),\n+                card,\n+            });\n+        }\n+        out.sort_by(|a, b| a.url.cmp(&b.url));\n+        Ok(out)\n+    }\n+\n+    async fn list_releases(&self, owner: &str, repo: &str) -> Result, String> {\n+        let arr = self\n+            .get_json_array_pages(&format!(\"/repos/{owner}/{repo}/releases?per_page=100\"))\n+            .await?;\n+        let mut out = Vec::new();\n+        for release in &arr {\n+            let Some(tag) = github_string(release, \"tag_name\") else {\n+                continue;\n+            };\n+            let title = github_string(release, \"name\").unwrap_or(tag);\n+            let url = github_string(release, \"html_url\")\n+                .map(|s| s.to_string())\n+                .unwrap_or_else(|| format!(\"https://github.com/{owner}/{repo}/releases/tag/{tag}\"));\n+            let card = card_for_release(release, &url, title);\n+            out.push(ResolvedChild {\n+                url: url.clone(),\n+                title: title.to_string(),\n+                card,\n+            });\n+        }\n+        out.sort_by(|a, b| a.url.cmp(&b.url));\n+        Ok(out)\n+    }\n+}\n+\n+fn github_segments(item: &ItemId) -> Option> {\n+    let url = url::Url::parse(item.as_str()).ok()?;\n+    if url.host_str()?.eq_ignore_ascii_case(\"github.com\") {\n+        Some(\n+            url.path_segments()\n+                .map(|segments| {\n+                    segments\n+                        .filter(|s| !s.is_empty())\n+                        .map(|s| s.to_ascii_lowercase())\n+                        .collect::>()\n+                })\n+                .unwrap_or_default(),\n+        )\n+    } else {\n+        None\n+    }\n+}\n+\n+fn title_case_segment(seg: &str) -> String {\n+    let mut c = seg.chars();\n+    match c.next() {\n+        None => String::new(),\n+        Some(f) => f.to_uppercase().chain(c).collect(),\n+    }\n+}\n+\n+fn github_repo_sections(owner: &str, repo: &str) -> Vec {\n+    [\n+        (\"issues\", \"GitHub issues for this repository.\"),\n+        (\"pulls\", \"GitHub pull requests for this repository.\"),\n+        (\"commits\", \"GitHub commits for this repository.\"),\n+        (\"releases\", \"GitHub releases for this repository.\"),\n+    ]\n+    .into_iter()\n+    .map(|(section, blurb)| {\n+        let url = format!(\"https://github.com/{owner}/{repo}/{section}\");\n+        let mut card = GithubImportCard::new(\n+            GithubImportKind::RepoSection,\n+            url.clone(),\n+            format!(\"{owner}/{repo} — {}\", title_case_segment(section)),\n+        );\n+        card.excerpt = Some(blurb.to_string());\n+        ResolvedChild {\n+            url,\n+            title: section.to_string(),\n+            card,\n+        }\n+    })\n+    .collect()\n+}\n+\n+fn resolver_thread_tag(item: &ItemId) -> String {\n+    let tail = item\n+        .display_path()\n+        .trim_start_matches(\"-/\")\n+        .replace('/', \":\")\n+        .replace('?', \":\");\n+    format!(\"import:{tail}\")\n+}\n+\n+fn children_to_dsl(children: &[ResolvedChild]) -> String {\n+    let mut out = String::new();\n+    for child in children {\n+        let json = serde_json::to_string(&child.card).unwrap_or_else(|_| \"{}\".to_string());\n+        let inner = format!(\"```slug-github-card\\n{json}\\n```\");\n+        out.push_str(&format!(\"{} {{\\n{}\\n}}\\n\\n\", child.url, inner));\n+    }\n+    out\n+}\n+\n+fn card_for_repo(repo: &Value, fallback_url: &str) -> GithubImportCard {\n+    let url = github_string(repo, \"html_url\")\n+        .map(|s| s.to_string())\n+        .filter(|s| !s.is_empty())\n+        .unwrap_or_else(|| fallback_url.to_string());\n+    let full_name = github_string(repo, \"full_name\")\n+        .or_else(|| github_string(repo, \"name\"))\n+        .unwrap_or(\"repository\");\n+    let mut card = GithubImportCard::new(GithubImportKind::Repo, url, full_name.to_string());\n+    if let Some(lang) = github_string(repo, \"language\") {\n+        card.sublines.push(format!(\"Language: {lang}\"));\n+    }\n+    if let Some(desc) = github_string(repo, \"description\") {\n+        card.excerpt = Some(desc.to_string());\n+    }\n+    card\n+}\n+\n+fn excerpt_from_github_body(body: Option<&str>) -> Option {\n+    let b = body?.trim();\n+    if b.is_empty() {\n+        return None;\n+    }\n+    let max = 1200usize;\n+    if b.len() <= max {\n+        Some(b.to_string())\n+    } else {\n+        Some(format!(\"{}…\", b.chars().take(max).collect::()))\n+    }\n+}\n+\n+fn card_for_issue(v: &Value, url: &str, kind: GithubImportKind) -> GithubImportCard {\n+    let number = v.get(\"number\").and_then(|n| n.as_i64());\n+    let title = github_string(v, \"title\").unwrap_or(\"Untitled\");\n+    let state = github_string(v, \"state\").unwrap_or(\"unknown\");\n+    let headline = match number {\n+        Some(n) => format!(\"#{n} {title}\"),\n+        None => title.to_string(),\n+    };\n+    let mut card = GithubImportCard::new(kind, url.to_string(), headline);\n+    card.sublines.push(format!(\"State: {state}\"));\n+    if let Some(a) = github_user_login(v) {\n+        card.sublines.push(format!(\"Author: @{a}\"));\n+    }\n+    let labels = github_labels(v);\n+    if !labels.is_empty() {\n+        card.sublines\n+            .push(format!(\"Labels: {}\", labels.join(\", \")));\n+    }\n+    card.excerpt = excerpt_from_github_body(github_string(v, \"body\"));\n+    card\n+}\n+\n+fn card_for_commit(v: &Value, url: &str, short_sha: &str, subject: &str) -> GithubImportCard {\n+    let headline = format!(\"{short_sha} {subject}\");\n+    let mut card = GithubImportCard::new(GithubImportKind::Commit, url.to_string(), headline);\n+    if let Some(name) = v\n+        .get(\"commit\")\n+        .and_then(|c| c.get(\"author\"))\n+        .and_then(|a| a.get(\"name\"))\n+        .and_then(|n| n.as_str())\n+        .filter(|s| !s.trim().is_empty())\n+    {\n+        card.sublines.push(format!(\"Author: {name}\"));\n+    }\n+    if let Some(login) = github_user_login(v) {\n+        card.sublines.push(format!(\"GitHub: @{login}\"));\n+    }\n+    if let Some(date) = v\n+        .get(\"commit\")\n+        .and_then(|c| c.get(\"author\"))\n+        .and_then(|a| a.get(\"date\"))\n+        .and_then(|d| d.as_str())\n+    {\n+        card.sublines.push(format!(\"Date: {date}\"));\n+    }\n+    if let Some(msg) = v\n+        .get(\"commit\")\n+        .and_then(|c| c.get(\"message\"))\n+        .and_then(|m| m.as_str())\n+    {\n+        card.excerpt = excerpt_from_github_body(Some(msg));\n+    }\n+    card\n+}\n+\n+fn card_for_release(v: &Value, url: &str, title: &str) -> GithubImportCard {\n+    let tag = github_string(v, \"tag_name\").unwrap_or(\"untagged\");\n+    let mut card = GithubImportCard::new(\n+        GithubImportKind::Release,\n+        url.to_string(),\n+        format!(\"Release — {title}\"),\n+    );\n+    card.sublines.push(format!(\"Tag: {tag}\"));\n+    if v.get(\"draft\").and_then(|b| b.as_bool()).unwrap_or(false) {\n+        card.sublines.push(\"Draft: yes\".to_string());\n+    }\n+    if v.get(\"prerelease\")\n+        .and_then(|b| b.as_bool())\n+        .unwrap_or(false)\n+    {\n+        card.sublines.push(\"Prerelease: yes\".to_string());\n+    }\n+    if let Some(a) = github_user_login(v) {\n+        card.sublines.push(format!(\"Author: @{a}\"));\n+    }\n+    if let Some(pub_at) = github_string(v, \"published_at\") {\n+        card.sublines.push(format!(\"Published: {pub_at}\"));\n+    }\n+    card.excerpt = excerpt_from_github_body(github_string(v, \"body\"));\n+    card\n+}\n+\n+fn github_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> {\n+    value\n+        .get(key)\n+        .and_then(|v| v.as_str())\n+        .filter(|s| !s.trim().is_empty())\n+}\n+\n+fn github_user_login(value: &Value) -> Option<&str> {\n+    value\n+        .get(\"user\")\n+        .and_then(|u| u.get(\"login\"))\n+        .and_then(|v| v.as_str())\n+        .filter(|s| !s.trim().is_empty())\n+}\n+\n+fn github_labels(value: &Value) -> Vec {\n+    value\n+        .get(\"labels\")\n+        .and_then(|v| v.as_array())\n+        .into_iter()\n+        .flat_map(|labels| labels.iter())\n+        .filter_map(|label| label.get(\"name\").and_then(|v| v.as_str()))\n+        .filter(|name| !name.trim().is_empty())\n+        .map(|name| name.to_string())\n+        .collect()\n+}\n+\n+pub async fn resolve_github_children(\n+    state: &AppState,\n+    room: &str,\n+    item: &ItemId,\n+) -> Result {\n+    if !state.github_resolver.can_resolve_children(item) {\n+        return Err(\"no GitHub resolver for this item\".to_string());\n+    }\n+\n+    let key = format!(\"github:{}:{}\", room.trim(), item.as_str());\n+    let now = now_ms();\n+    {\n+        let mut runs = state.resolver_runs.write().await;\n+        if let Some(last) = runs.get(&key) {\n+            let remaining = GITHUB_RESOLVER_COOLDOWN_MS - (now - *last);\n+            if remaining > 0 {\n+                return Err(format!(\n+                    \"GitHub resolver cooldown: try again in {}s\",\n+                    (remaining + 999) / 1000\n+                ));\n+            }\n+        }\n+        runs.insert(key, now);\n+    }\n+\n+    let children = state.github_resolver.list_children(item).await?;\n+    if children.is_empty() {\n+        return Ok(0);\n+    }\n+    let text = children_to_dsl(&children);\n+    let thread_tag = resolver_thread_tag(item);\n+    let (tx, rx) = oneshot::channel();\n+    state\n+        .write_tx\n+        .send(WriteCmd::SystemIngest {\n+            room: room.to_string(),\n+            thread_tag,\n+            text,\n+            principal: GITHUB_SYSTEM_PRINCIPAL.to_string(),\n+            reply: tx,\n+        })\n+        .await\n+        .map_err(|_| \"writer unavailable\".to_string())?;\n+    rx.await\n+        .map_err(|_| \"writer dropped\".to_string())?\n+        .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!(\"{msg}: {h}\")))?;\n+    Ok(children.len())\n+}\n+\n+fn extract_fence<'a>(body: &'a str, lang: &str) -> Option<&'a str> {\n+    let b = body.trim();\n+    let prefix = format!(\"```{lang}\");\n+    let rest = b.strip_prefix(prefix.as_str())?;\n+    let rest = rest\n+        .strip_prefix('\\n')\n+        .or_else(|| rest.strip_prefix('\\r'))\n+        .unwrap_or(rest);\n+    let end = rest.find(\"\\n```\")?;\n+    Some(rest[..end].trim())\n+}\n+\n+fn parse_github_import_from_body(body: &str) -> Option {\n+    let trimmed = body.trim();\n+    if let Some(json) = extract_fence(trimmed, \"slug-github-card\") {\n+        let c: GithubImportCard = serde_json::from_str(json).ok()?;\n+        return (c.v == 1 && (c.schema.is_empty() || c.schema == SLUG_GITHUB_SCHEMA)).then_some(c);\n+    }\n+    if let Some(json) = extract_fence(trimmed, \"json\") {\n+        if let Ok(c) = serde_json::from_str::(json) {\n+            if c.v == 1\n+                && (c.schema == SLUG_GITHUB_SCHEMA\n+                    || (c.schema.is_empty() && c.url.contains(\"github.com\")))\n+            {\n+                return Some(c);\n+            }\n+        }\n+    }\n+    if trimmed.starts_with('{') {\n+        let c: GithubImportCard = serde_json::from_str(trimmed).ok()?;\n+        return (c.v == 1\n+            && (c.schema == SLUG_GITHUB_SCHEMA\n+                || (c.schema.is_empty() && c.url.contains(\"github.com\"))))\n+        .then_some(c);\n+    }\n+    None\n+}\n+\n+fn kind_badge(kind: &GithubImportKind) -> &'static str {\n+    match kind {\n+        GithubImportKind::Repo => \"GitHub · repository\",\n+        GithubImportKind::RepoSection => \"GitHub · tree\",\n+        GithubImportKind::Issue => \"GitHub · issue\",\n+        GithubImportKind::Pull => \"GitHub · pull request\",\n+        GithubImportKind::Commit => \"GitHub · commit\",\n+        GithubImportKind::Release => \"GitHub · release\",\n+    }\n+}\n+\n+fn render_github_card(card: &GithubImportCard) -> maud::Markup {\n+    html! {\n+        article.github-import-card {\n+            header.github-import-card__hdr {\n+                span class=\"github-import-card__badge\" { (kind_badge(&card.kind)) }\n+                h3.github-import-card__title { (card.headline.as_str()) }\n+            }\n+            @if !card.sublines.is_empty() {\n+                ul.github-import-card__meta {\n+                    @for line in &card.sublines {\n+                        li { (line.as_str()) }\n+                    }\n+                }\n+            }\n+            @if let Some(ex) = &card.excerpt {\n+                div.github-import-card__excerpt {\n+                    @for block in ex.split(\"\\n\\n\") {\n+                        @if !block.trim().is_empty() {\n+                            p { (block) }\n+                        }\n+                    }\n+                }\n+            }\n+            p.github-import-card__link {\n+                a href=(card.url.as_str()) rel=\"noopener noreferrer\" target=\"_blank\" {\n+                    \"Open on GitHub\"\n+                }\n+            }\n+        }\n+    }\n+}\n+\n+/// Rich HTML for bodies that contain a [`GithubImportCard`] fence (or equivalent JSON).\n+pub fn try_render_github_import_markup(raw: &str) -> Option {\n+    let card = parse_github_import_from_body(raw)?;\n+    Some(render_github_card(&card))\n+}\n+\n+#[async_trait]\n+impl ExternalResolver for GitHubResolver {\n+    fn domain_match(&self) -> &'static str {\n+        \"github.com\"\n+    }\n+\n+    fn normalize(&self, path: &str) -> String {\n+        path.to_string()\n+    }\n+\n+    async fn fetch_body(&self, _item: &ItemId) -> Result {\n+        Err(\"GitHub fetch_body not implemented\".to_string())\n+    }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+    use super::*;\n+\n+    #[test]\n+    fn github_segments_parse_normalized_url() {\n+        let item = ItemId::parse(\"https://github.com/Sortersocial/Slug/issues\").unwrap();\n+        assert_eq!(\n+            github_segments(&item),\n+            Some(vec![\n+                \"sortersocial\".to_string(),\n+                \"slug\".to_string(),\n+                \"issues\".to_string()\n+            ])\n+        );\n+    }\n+\n+    #[test]\n+    fn repo_sections_are_direct_children() {\n+        let sections = github_repo_sections(\"sortersocial\", \"slug\");\n+        let urls: Vec = sections.into_iter().map(|c| c.url).collect();\n+        assert!(urls.contains(&\"https://github.com/sortersocial/slug/issues\".to_string()));\n+        assert!(urls.contains(&\"https://github.com/sortersocial/slug/pulls\".to_string()));\n+    }\n+\n+    #[test]\n+    fn children_to_dsl_wraps_slug_github_card() {\n+        let dsl = children_to_dsl(&[ResolvedChild {\n+            url: \"https://github.com/o/r/issues/1\".into(),\n+            title: \"#1 title\".into(),\n+            card: GithubImportCard::new(\n+                GithubImportKind::Issue,\n+                \"https://github.com/o/r/issues/1\".into(),\n+                \"#1 title\".into(),\n+            ),\n+        }]);\n+        assert!(dsl.contains(\"https://github.com/o/r/issues/1\"));\n+        assert!(dsl.contains(\"```slug-github-card\"));\n+        assert!(dsl.contains(\"\\\"schema\\\":\\\"slug_github_import\\\"\"));\n+    }\n+\n+    #[test]\n+    fn parse_accepts_slug_github_fence() {\n+        let card = GithubImportCard::new(\n+            GithubImportKind::Repo,\n+            \"https://github.com/o/r\".into(),\n+            \"o/r\".into(),\n+        );\n+        let body = format!(\"```slug-github-card\\n{}\\n```\\n\", serde_json::to_string(&card).unwrap());\n+        let parsed = parse_github_import_from_body(&body).expect(\"parses\");\n+        assert_eq!(parsed, card);\n+    }\n+\n+    #[test]\n+    fn parse_accepts_schema_json_fence() {\n+        let card = GithubImportCard::new(\n+            GithubImportKind::Issue,\n+            \"https://github.com/o/r/issues/2\".into(),\n+            \"#2 hi\".into(),\n+        );\n+        let json = serde_json::to_string(&card).unwrap();\n+        let body = format!(\"```json\\n{json}\\n```\");\n+        let parsed = parse_github_import_from_body(&body).expect(\"parses json fence\");\n+        assert_eq!(parsed.headline, \"#2 hi\");\n+    }\n+\n+    #[test]\n+    fn issue_card_includes_author_and_excerpt() {\n+        let issue = serde_json::json!({\n+            \"number\": 12,\n+            \"title\": \"Render children\",\n+            \"state\": \"open\",\n+            \"html_url\": \"https://github.com/o/r/issues/12\",\n+            \"user\": {\"login\": \"octo\"},\n+            \"labels\": [{\"name\": \"bug\"}],\n+            \"body\": \"The issue body.\"\n+        });\n+        let card = card_for_issue(\n+            &issue,\n+            \"https://github.com/o/r/issues/12\",\n+            GithubImportKind::Issue,\n+        );\n+        assert!(card.sublines.iter().any(|l| l.contains(\"@octo\")));\n+        assert_eq!(card.excerpt.as_deref(), Some(\"The issue body.\").as_deref());\n+    }\n+}\ndiff --git a/server/src/resolvers/mod.rs b/server/src/resolvers/mod.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3e4caad081acdd2f89cba9f661de43996d6470f3\n--- /dev/null\n+++ b/server/src/resolvers/mod.rs\n@@ -0,0 +1,18 @@\n+//! Domain resolvers (GitHub, …) and matching HTML renderers for imported item bodies.\n+//!\n+//! Resolver output is ingested as DSL; bodies may embed a `slug-github-card` fenced JSON\n+//! envelope that [`crate::html::render_item_body_in_scope`] renders instead of a raw `
    `.\n+\n+pub mod github;\n+pub mod default_external;\n+\n+pub use default_external::DefaultExternalResolver;\n+pub use github::{\n+    resolve_github_children, try_render_github_import_markup, ExternalResolver, GitHubResolver,\n+    GithubImportCard, GithubImportKind, ResolvedChild,\n+};\n+\n+/// Extension point: add more `try_render_*` calls here as new resolvers ship.\n+pub fn try_render_resolver_item_body(raw: &str) -> Option {\n+    github::try_render_github_import_markup(raw)\n+}\ndiff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs\nindex 06c560b8eff09b34896d3935d3917fb28f602bc6..2361b2be5ae6b8e1813b6b7ebd5bbf317429b6ad 100644\n--- a/server/src/scope_rank.rs\n+++ b/server/src/scope_rank.rs\n@@ -162,6 +162,45 @@ pub fn build_children_rankings(content: &ContentState, parent: &ItemId) -> Child\n     build_rankings_for_item_set(content, &items)\n }\n \n+/// Host-only `https://…` roots for the external garden index (`/-/`).\n+///\n+/// Includes every `https://host` ancestor of any [`ItemId::Web`] item that appears in\n+/// `content.items`, as a parent key in `item_children`, or as a child in `item_children`\n+/// (so implied “ghost” parents created only via [`ReducerState::add_child_edge`] still show up).\n+pub fn external_root_host_items(content: &ContentState) -> Vec {\n+    let mut hosts: HashSet = HashSet::new();\n+\n+    let mut consider = |id: ItemId| {\n+        let id = id.normalized_storage();\n+        if !matches!(&id, ItemId::Web(_)) {\n+            return;\n+        }\n+        let mut cur = id;\n+        while let Some(p) = cur.parent() {\n+            cur = p.normalized_storage();\n+        }\n+        if matches!(cur, ItemId::Web(_)) {\n+            hosts.insert(cur);\n+        }\n+    };\n+\n+    for it in &content.items {\n+        consider(it.clone());\n+    }\n+    for parent in content.item_children.keys() {\n+        consider(parent.clone());\n+    }\n+    for set in content.item_children.values() {\n+        for ch in set {\n+            consider(ch.clone());\n+        }\n+    }\n+\n+    let mut out: Vec = hosts.into_iter().collect();\n+    out.sort();\n+    out\n+}\n+\n pub fn is_pair_voted_in_group(group: &GroupState, a: &ItemId, b: &ItemId) -> bool {\n     let Some(&a_idx) = group.item_to_idx.get(a) else {\n         return false;\n@@ -305,4 +344,29 @@ mod tests {\n         assert!(next.0 == c || next.1 == c);\n         assert_ne!(canonical_pair(&next.0, &next.1), canonical_pair(&a, &b));\n     }\n+\n+    #[test]\n+    fn external_root_hosts_include_ghost_chain_hosts() {\n+        use crate::reducer::ContentState;\n+        let gh = ItemId::parse(\"https://github.com\").unwrap();\n+        let org = ItemId::parse(\"https://github.com/org\").unwrap();\n+        let repo = ItemId::parse(\"https://github.com/org/rep\").unwrap();\n+        let mut item_children: HashMap> = HashMap::new();\n+        item_children.entry(gh.clone()).or_default().insert(org.clone());\n+        item_children.entry(org.clone()).or_default().insert(repo.clone());\n+        let mut items = HashSet::new();\n+        items.insert(repo.clone());\n+        let content = ContentState {\n+            ranking_group: crate::reducer::GroupState::new(),\n+            items,\n+            item_bodies: HashMap::new(),\n+            item_children,\n+            item_votes: HashMap::new(),\n+            item_snippets: HashMap::new(),\n+            item_threads: HashMap::new(),\n+            rank_history: HashMap::new(),\n+        };\n+        let roots = external_root_host_items(&content);\n+        assert_eq!(roots, vec![gh]);\n+    }\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 48298e2e66456268d23a6462536eb32bfeb5f29b..648ab5304764a329fcabbbbcd3782b94e3e005a8 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -4,7 +4,7 @@ use std::sync::Arc;\n use tokio::sync::{broadcast, mpsc, RwLock};\n \n use crate::{\n-    event_log::EventLog, events::ThreadCapability, external_resolver::GitHubResolver,\n+    event_log::EventLog, events::ThreadCapability, resolvers::GitHubResolver,\n     reducer::ReducerState, write_cmd::WriteCmd,\n };\n \ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex 184e11a590e7019773f7f0abfa41e79161556c71..7ea5f502f9b0b6341ce56d60ae883479070760d6 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -1023,6 +1023,23 @@ body.view-vote-compare .vote-compare-shell > h2 {\n   line-height: 1.35;\n   padding: 8px 10px;\n }\n+.vote-compare-item-body .item-body-rich {\n+  min-width: 0;\n+  text-align: start;\n+}\n+.vote-compare-right .vote-compare-item-body .item-body-rich {\n+  display: flex;\n+  flex-direction: column;\n+  align-items: flex-end;\n+}\n+.vote-compare-item-body .item-body-rich article.github-import-card {\n+  box-sizing: border-box;\n+  width: 100%;\n+  max-width: min(100%, 420px);\n+}\n+.vote-compare-right .vote-compare-item-body .item-body-rich article.github-import-card {\n+  margin-left: auto;\n+}\n .vote-compare-item-body-empty {\n   font-size: 12px;\n   margin: 8px 0 0;\n@@ -1675,3 +1692,47 @@ body.view-ontology-light .rank-history-cause {\n body.view-ontology-light .rank-history-vote {\n   margin-top: 6px;\n }\n+\n+/* GitHub resolver import cards (rich bodies on -/ garden + vote compare) */\n+article.github-import-card {\n+  border: 1px solid var(--lo);\n+  background: var(--g2);\n+  border-radius: 6px;\n+  padding: 12px 14px;\n+  margin: 8px 0;\n+  max-width: 100%;\n+}\n+.github-import-card__hdr {\n+  margin-bottom: 6px;\n+}\n+.github-import-card__badge {\n+  display: block;\n+  font-size: 0.78em;\n+  color: var(--muted);\n+  margin-bottom: 4px;\n+}\n+.github-import-card__title {\n+  margin: 0;\n+  font-size: 1.05em;\n+  font-weight: 600;\n+}\n+ul.github-import-card__meta {\n+  margin: 8px 0 0 1.1em;\n+  padding: 0;\n+  font-size: 0.9em;\n+}\n+.github-import-card__meta li {\n+  margin: 2px 0;\n+}\n+.github-import-card__excerpt {\n+  margin-top: 10px;\n+  font-size: 0.92em;\n+  white-space: pre-wrap;\n+}\n+.github-import-card__excerpt p {\n+  margin: 6px 0;\n+}\n+.github-import-card__link {\n+  margin-top: 12px;\n+  font-size: 0.95em;\n+}\ndiff --git a/server/static/theme_retro.css b/server/static/theme_retro.css\nindex 6747f59eb1ec5c335029fe92d4e5c55b3125a210..d366be6fc8e8fcbc8122b8954b1e356ee36e6bc9 100644\n--- a/server/static/theme_retro.css\n+++ b/server/static/theme_retro.css\n@@ -278,3 +278,20 @@ body.view-ontology .vote-compare-item-body pre {\n   border: 1px solid #ccc;\n   padding: 0.5rem 0.65rem;\n }\n+body.view-ontology .vote-compare-item-body .item-body-rich {\n+  min-width: 0;\n+  text-align: start;\n+}\n+body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich {\n+  display: flex;\n+  flex-direction: column;\n+  align-items: flex-end;\n+}\n+body.view-ontology .vote-compare-item-body .item-body-rich article.github-import-card {\n+  box-sizing: border-box;\n+  width: 100%;\n+  max-width: min(100%, 420px);\n+}\n+body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich article.github-import-card {\n+  margin-left: auto;\n+}\ndiff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css\nindex 55d984a6dd70ebeadeca7baef86b844955f1d78c..d5bc384437f05f001924630947457d416772e950 100644\n--- a/server/static/theme_retro_craft.css\n+++ b/server/static/theme_retro_craft.css\n@@ -907,6 +907,23 @@ body.view-ontology .vote-compare-item-body pre {\n   line-height: 1.35;\n   padding: 0.55rem 0.65rem;\n }\n+body.view-ontology .vote-compare-item-body .item-body-rich {\n+  min-width: 0;\n+  text-align: start;\n+}\n+body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich {\n+  display: flex;\n+  flex-direction: column;\n+  align-items: flex-end;\n+}\n+body.view-ontology .vote-compare-item-body .item-body-rich article.github-import-card {\n+  box-sizing: border-box;\n+  width: 100%;\n+  max-width: min(100%, 420px);\n+}\n+body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich article.github-import-card {\n+  margin-left: auto;\n+}\n body.view-ontology .vote-compare-item-body-empty {\n   font-size: 0.78rem;\n   margin: 0.45rem 0 0;\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex fb0b9335440181d4d50104d37926b0b2eeeb602a..d979000292b6a39db7c7b54f2b804adb9cd39369 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -3,7 +3,7 @@ use sha2::{Digest, Sha256};\n use slug_types::{room_route_segment, ItemId};\n use slugsocial_server::{\n     event_log::EventLog,\n-    events::{Event, TokenIssued, UserRegistered},\n+    events::{Event, Ingest, TokenIssued, UserRegistered},\n     middleware::canonical_view_url,\n     spawn_writer_actor_for_test,\n     state::{AppConfig, AppState},\n@@ -1614,6 +1614,71 @@ async fn test_view_counts_increment_and_display() {\n     );\n }\n \n+#[tokio::test]\n+async fn test_vote_compare_renders_github_import_cards() {\n+    let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+    let client = reqwest::Client::new();\n+\n+    let raw = \"@00000000-0000-0000-0000-000000000000:test:local/test\\n\\\n+https://github.com/ghvotehi/a/issues/9 {\\n\\\n+```slug-github-card\\n\\\n+{\\\"v\\\":1,\\\"schema\\\":\\\"slug_github_import\\\",\\\"kind\\\":\\\"issue\\\",\\\"url\\\":\\\"https://github.com/ghvotehi/a/issues/9\\\",\\\"headline\\\":\\\"#9 Left corner\\\",\\\"sublines\\\":[\\\"State: open\\\"]}\\n\\\n+```\\n\\\n+}\\n\\\n+\\n\\\n+https://github.com/ghvotehi/a/issues/10 {\\n\\\n+```slug-github-card\\n\\\n+{\\\"v\\\":1,\\\"schema\\\":\\\"slug_github_import\\\",\\\"kind\\\":\\\"issue\\\",\\\"url\\\":\\\"https://github.com/ghvotehi/a/issues/10\\\",\\\"headline\\\":\\\"#10 Right corner\\\",\\\"sublines\\\":[\\\"State: open\\\"]}\\n\\\n+```\\n\\\n+}\\n\";\n+\n+    {\n+        let mut w = state.reduced.write().await;\n+        w.apply_event(Event::Ingest(Ingest {\n+            ts: 10,\n+            id: \"ing-vote-github-cards\".to_string(),\n+            raw: raw.to_string(),\n+            principal: \"testuser\".to_string(),\n+            delegate: Some(\n+                \"00000000-0000-0000-0000-000000000000:test:local/test\".to_string(),\n+            ),\n+            room_id: \"public\".to_string(),\n+            thread_tag: \"gh-vote-cards\".to_string(),\n+        }));\n+    }\n+\n+    let left = ItemId::parse(\"https://github.com/ghvotehi/a/issues/9\")\n+        .unwrap()\n+        .normalized_storage()\n+        .to_storage_string();\n+    let right = ItemId::parse(\"https://github.com/ghvotehi/a/issues/10\")\n+        .unwrap()\n+        .normalized_storage()\n+        .to_storage_string();\n+    let q = format!(\n+        \"/vote/compare?left={}&right={}\",\n+        urlencoding::encode(&left),\n+        urlencoding::encode(&right)\n+    );\n+    let resp = client\n+        .get(format!(\"http://{addr}{q}\"))\n+        .send()\n+        .await\n+        .unwrap();\n+    assert!(resp.status().is_success(), \"{}\", resp.status());\n+    let body = resp.text().await.unwrap();\n+    let n_cards = body.matches(\"github-import-card\").count();\n+    assert!(\n+        n_cards >= 2,\n+        \"expected two GitHub import cards on vote compare, count={n_cards}, snippet={}\",\n+        body.chars().take(1500).collect::()\n+    );\n+    assert!(body.contains(\"vote-compare-left\"));\n+    assert!(body.contains(\"vote-compare-right\"));\n+    assert!(body.contains(\"#9 Left corner\"));\n+    assert!(body.contains(\"#10 Right corner\"));\n+}\n+\n #[tokio::test]\n async fn test_search_handles_multibyte_unicode() {\n     // HTML search pages are offline during the auth-v3 refactor.\n","role":"user"}],"model":"~x-ai/grok-latest"}