constitution · epochs · watch · epoch 3

comparison

c_99071b4accf5 (tommy-mor) vs c_841186378da0 (tommy-mor)

download prompt · raw event · cmp_2a9741cd273322

council reasoning

~anthropic/claude-sonnet-latest · winner A · 9:1 · permalink

Side A delivers substantive, tested improvements: safer OAuth redirect handling, richer GitHub resolver output with pagination and readable bodies, a real 'suggest next pair' algorithm with tests, and UI/CSS polish tied to those features. Side B ('nice') is a minor, low-effort commit that mostly renames a package, tweaks .gitignore, and arguably regresses UX by replacing a multiline textarea with a single-line input, offering little lasting value.

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

Side A delivers lasting product value: safer auth next-redirects, GitHub resolver pagination/commits/releases with readable bodies and status morphs, smart next-pair suggestion in vote-compare (plus UI and tests). Side B is mostly local tooling/CSS churn (gitignore, env rename, button styles) and a minor input tweak, with little durable design impact.

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

Side A delivers substantial functional improvements across the application: it adds safe local post-login redirects, fixes the vote-compare flow by suggesting and updating the next pair after voting, improves GitHub resolver behavior with pagination, commits/releases support and readable imported content, and adds corresponding tests. Side B is mostly development/configuration and UI styling changes (renaming the watched package, ignoring a local data directory, changing a textarea to a text input, and CSS tweaks), with little lasting impact on core functionality.

sides

A — c_99071b4accf5 (tommy-mor)

message

[e8c85249] Fix GitHub resolver and vote compare flow (#149)

* Fix GitHub resolver and vote compare flow

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

* Fix vote compare next pair helper

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

* Extend GitHub resolver and vote pair updates

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

* Fix resolver browser refresh coverage

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

* Stabilize GitHub resolver browser test

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

---------

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

diff preview

diff --git a/agents.md b/agents.md
index c66f33789441eea193b4354fce4c03b7fffdd639..7508234d9b04223d0e64cfe69fedbebd06a256b5 100644
--- a/agents.md
+++ b/agents.md
@@ -36,12 +36,18 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma
 
 - **`HtmlUiAction` / `POST /ui`** (`server/src/html/ui_action.rs`, `server/src/api/ui_html.rs`): **Browser session** (cookie) UI commands. Payload is `__rpc__` + form fields. Most responses are **JS morphs**; some actions return **HTTP redirects** (see below).
 
+- **Do not add one-off POST routes** for browser mutations. New browser actions belong in **`HtmlUiAction`** behind **`POST /ui`**; new programmatic verbs belong in **`RpcCommand`** behind **`POST /api/v0/rpc`**. Ordinary shareable pages remain normal **`GET`** routes.
+
 - **Non-morph `POST /ui` responses:** **`SetGardenPin`** returns **`303 See Other`** and **`Set-Cookie`** (same as **`POST /theme`**). Garden pin/unpin is a normal **`<form method="POST" action="/ui" data-navigate="full">`** — browser navigation applies cookies reliably (see **`test/browser_garden_pin.clj`**). Each **`__rpc__`** payload includes **`form_action: "/ui"`**; **`post_ui_html`** rejects mismatches to bind tokens to the UI endpoint.
 
-- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-compare-preview`** (new ingest card), **`#vote-edge-history-region`** (recomputed **`<ul>`** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer). 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.
+- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`<ul>`** — 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.
+
+- **`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.
 
 - **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.
 
+- **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection.
+
 **Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate.
 
 ---
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index c9c4082f251838b5ac46de7ce48392c136883d55..88bb3335e4873643530351266d213f258da5893e 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -8,7 +8,10 @@ use axum::{
 use axum_extra::extract::cookie::CookieJar;
 use base64::Engine;
 use serde::Deserialize;
-use slug_types::{PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse, WhoamiResponse};
+use slug_types::{
+    PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse,
+    WhoamiResponse,
+};
 use std::{collections::HashMap, sync::Arc};
 use tokio::sync::{oneshot, RwLock};
 
@@ -17,7 +20,8 @@ use crate::{
     events::{Event, TokenIssued},
     html::{
         auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,
-        choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder,
+        choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri,
+        JsBuilder,
     },
     identity::{parse_agent, parse_username},
     reducer::ReducerState,
@@ -36,12 +40,26 @@ pub const SLUG_SESSION_COOKIE: &str = "slug_session";
 
 /// `Set-Cookie` header value (full attribute string).
 pub fn session_cookie_header_value(bearer: &str) -> HeaderValue {
-    let s = format!(
-        "{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
-    );
+    let s =
+        format!("{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000");
     HeaderValue::from_str(&s).expect("session cookie value must be ASCII")
 }
 
+fn safe_local_redirect(raw: Option<&str>) -> Option<String> {
+    let s = raw?.trim();
+    if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 {
+        Some(s.to_string())
+    } else {
+        None
+    }
+}
+
+fn redirect_query(next: Option<&str>) -> String {
+    safe_local_redirect(next)
+        .map(|n| format!("&next={}", urlencoding::encode(&n)))
+        .unwrap_or_default()
+}
+
 fn js_form_error_fragment(session: &str, error: &str) -> Response {
     JsBuilder::new()
         .id("choose-username-form")
@@ -49,11 +67,11 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response {
         .into_response()
 }
 
-fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {
+fn js_signed_in_fragment(bearer: &str, jar: &CookieJar, redirect_to: &str) -> Response {
     let mut response = JsBuilder::new()
         .id("choose-username-form")
         .morph_inner(auth_signed_in_fragment())
-        .redirect("/auth/complete")
+        .redirect(redirect_to)
         .into_response();
     let headers = response.headers_mut();
     headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));
@@ -64,7 +82,11 @@ fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {
 }
 
 /// Resolve the signed-in username from `Authorization: Bearer` or `slug_session` cookie.
-pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option<String> {
+pub fn optional_principal(
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    reduced: &ReducerState,
+) -> Option<String> {
     if let Ok(u) = verify_bearer_principal(headers, reduced) {
         return Some(u);
     }
@@ -80,7 +102,11 @@ pub struct WebSession {
 }
 
 /// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar).
-pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option<WebSession> {
+pub fn resolve_web_session(
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    reduced: &ReducerState,
+) -> Option<WebSession> {
     let username = optional_principal(headers, jar, reduced)?;
     let bearer = headers
         .get(header::AUTHORIZATION)
@@ -90,7 +116,12 @@ pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduc
     Some(WebSession { username, bearer })
 }
 
-fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {
+fn redirect_with_session_cookie(
+    public_url: &str,
+    path_and_query: &str,
+    bearer: &str,
+    jar: &CookieJar,
+) -> Response {
     let mut res = Response::builder()
         .status(StatusCode::TEMPORARY_REDIRECT)
         .header(header::LOCATION, format!("{public_url}{path_and_query}"))
@@ -112,21 +143,32 @@ fn pending_sessions(state: &AppState) -> Arc<RwLock<HashMap<String, PendingSessi
 /// Safe here because the token was received directly from Google's token endpoint over TLS.
 fn extract_jwt_sub(jwt: &str) -> Option<String> {
     let payload_b64 = jwt.split('.').nth(1)?;
-    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
+    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
+        .decode(payload_b64)
+        .ok()?;
     let v: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
     v.get("sub")?.as_str().map(|s| s.to_string())
 }
 
 pub(crate) fn parse_bearer(headers: &HeaderMap) -> Result<String, (StatusCode, String)> {
     let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
-        return Err((StatusCode::UNAUTHORIZED, "missing Authorization header".to_string()));
+        return Err((
+            StatusCode::UNAUTHORIZED,
+            "missing Authorization header".to_string(),
+        ));
     };
     let Ok(s) = value.to_str() else {
-        return Err((StatusCode::UNAUTHORIZED, "invalid Authorization header".to_string()));
+        return Err((
+            StatusCode::UNAUTHORIZED,
+            "invalid Authorization header".to_string(),
+        ));
     };
     let s = s.trim();
     let Some(rest) = s.strip_prefix("Bearer ") else {
-        return Err((StatusCode::UNAUTHORIZED, "Authorization must be Bearer".to_string()));
+        return Err((
+            StatusCode::UNAUTHORIZED,
+            "Authorization must be Bearer".to_string(),
+        ));
     };
     Ok(rest.trim().to_string())
 }
@@ -140,7 +182,10 @@ pub fn verify_bearer_principal(
     verify_token(reduced, &bearer)
 }
 
-pub(crate) fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result<String, (StatusCode, String)> {
+pub(crate) fn verify_token(
+    reduced: &crate::reducer::ReducerState,
+    bearer: &str,
+) -> Result<String, (StatusCode, String)> {
     // slug_<token_id>_<secret>
     let Some(rest) = bearer.strip_prefix("slug_") else {
         return Err((StatusCode::UNAUTHORIZED, "invalid token format".to_string()));
@@ -199,9 +244,25 @@ pub(crate) fn issue_token_for_user(stored_username: &str) -> (String, TokenIssue
 #[derive(Debug, Deserialize)]
 pub struct AuthLoginQuery {
     pub session: String,
+    #[serde(default)]
+    pub next: Option<String>,
+    #[serde(default)]
+    pub redirect: Option<String>,
 }
 
-pub async fn get_join_invite(Path(token): Path<String>, State(state): State<AppState>) -> impl IntoResponse {
+#[derive(Debug, Deserialize)]
+pub struct JoinInviteQuery {
+    #[serde(default)]
+    pub next: Option<String>,
+    #[serde(default)]
+    pub redirect: Option<String>,
+}
+
+pub async fn get_join_invite(
+    Path(token): Path<String>,
+    Query(q): Query<JoinInviteQuery>,
+    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();
@@ -219,37 +280,57 @@ pub async fn get_join_invite(Path(token): Path<String>, State(state): State<AppS
     }
 
     let session = format!("p_{}", uuid::Uuid::new_v4().simple());
+    let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()));
     let s = PendingSession {
         agent: INVITE_BROWSER_AGENT.to_string(),
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
         redeem_invite: Some(token),
+        redirect_next: redirect_next.clone(),
         complete: None,
     };
-    state.pending_sessi

… preview truncated; 86,121 characters omitted

download full diff A

B — c_841186378da0 (tommy-mor)

message

[e8fdeacf] nice

diff preview

diff --git a/.gitignore b/.gitignore
index 73e8f22cf0d39c706e7cdce5e39f1903a0f9181b..4c7073f9fac0c30fd2050d79a60ef447af58ebeb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@
 .DS_Store
 data/
 repomix-output.xml
+dev-data/
diff --git a/bb.edn b/bb.edn
index 1e8ec120921761a9a4f28f36578efb1567407cb3..43cc60c9461466e0f7cb08cfea5c600edf95a662 100644
--- a/bb.edn
+++ b/bb.edn
@@ -9,11 +9,10 @@
    :task (do
            (deref (p/process ["mkdir" "-p" "dev-data"] {:inherit true}))
            (deref (p/process ["cargo" "watch"
-                              "-x" "run -p server"
+                              "-x" "run -p sorter2-server"
                               "-w" "server/"]
                              {:inherit true
                               :env (merge (into {} (System/getenv))
-                                          {"SLUG_DATA_DIR" "dev-data"
-                                           "SLUG_KEYS"     "dev:dev"
-                                           "PORT"          "8080"
-                                           "RUST_LOG"      "info"})})))}}}
+                                          {"SORTER2_DATA_DIR" "dev-data"
+                                           "PORT"             "8080"
+                                           "RUST_LOG"         "info"})})))}}}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 322dffefeaa3b8560c1c2b2f70ba8e5a8c555022..0acba3ac745f23be473243e0a9b9cc6e57e8d86f 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -232,7 +232,8 @@ pub fn input_panel(query: &str, error: Option<&str>) -> Markup {
     html! {
         section id="parser-panel" class="demo-panel" {
             form method="post" action="/ui" id="parser-form" {
-                textarea
+                input
+                    type="text"
                     name="query"
                     id="parser-input"
                     rows="3"
diff --git a/server/static/sorter.css b/server/static/sorter.css
index 04f4fabdea3de7c3bc54805d0d3d9e0cea6eeefd..a648e88a57290346b1069868134a212b180def52 100644
--- a/server/static/sorter.css
+++ b/server/static/sorter.css
@@ -34,29 +34,20 @@ body {
   font-size: 0.75rem;
 }
 
-.demo-panel {
-  max-width: 40rem;
-  margin: 4rem auto 2rem;
-  padding: 2rem;
-  background: var(--panel);
-  border: 1px solid var(--border);
-  border-radius: 8px;
-}
-
-.demo-panel h1 {
-  margin-top: 0;
-}
-
 .btn-primary {
   background: var(--accent);
   color: #0f1115;
-  border: none;
+  border-style: outset;
+  border-width: 3px;
   padding: 0.5rem 1rem;
-  border-radius: 4px;
   font-size: 1rem;
   cursor: pointer;
 }
 
+.btn-primary:active {
+  border-style: inset;
+}
+
 .btn-primary:hover {
   filter: brightness(1.1);
 }
@@ -103,11 +94,15 @@ code {
   color: var(--muted);
 }
 
+#parser-panel {
+  display: flex;
+  flex-direction: column;
+}
+
 #parser-input {
-  width: 100%;
+  width: 90%;
   padding: 0.5rem;
   border: 1px solid var(--border);
-  border-radius: 4px;
   background: var(--bg);
   color: var(--fg);
   font-size: 1rem;
@@ -115,7 +110,12 @@ code {
   resize: vertical;
 }
 
+#parser-input:focus-visible {
+  outline: 0;
+}
+
 #parser-form .btn-primary {
+  width: 10%;
   margin-top: 0.5rem;
 }
 

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.