diff --git a/.claude/skills/askdiff-dev/SKILL.md b/.claude/skills/askdiff-dev/SKILL.md index 8cf94d0..207da27 100644 --- a/.claude/skills/askdiff-dev/SKILL.md +++ b/.claude/skills/askdiff-dev/SKILL.md @@ -15,27 +15,39 @@ proxy `/ws` to the WS server, so the UI uses the same same-origin Use this when editing `packages/server` or `packages/ui-browser` and you want changes to reload instantly instead of rebuilding/republishing. -> **Keep Step 1–3 in sync with `.claude/skills/askdiff/SKILL.md`.** The -> diff-resolution flow (interpret → git → temp file → label) must behave -> identically in both skills; only Step 4 (launch) differs. If you change -> the table or the bash blocks below, change them in the user-facing +> **Keep Steps 1–4 in sync with `.claude/skills/askdiff/SKILL.md`.** The +> diff-resolution and session-resolution flow (interpret → git → temp file +> → label → pick session) must behave identically in both skills; only +> Step 5 (launch) differs. If you change any block below — including the +> session-resolution logic in Step 4 — change it in the user-facing > `askdiff` skill too. -## Step 1 — figure out which diff the user wants +## Step 1 — figure out which diff the user wants (and which session) Look at the message that invoked this skill. Anything after `/askdiff-dev` -is the user's diff description (may be empty). +is free-form natural language that may carry **two** kinds of information: -| User said | git command | Suggested label | +1. A **diff description** — what to diff (handled by the table/ladder + below). This part is what Step 2 turns into a `git diff` command. +2. An optional **session hint** — which Claude session to attach to + (handled by the *Session hint* subsection at the end of this step, + then resolved in Step 4). + +Either or both may be empty. The diff-description part may be empty +(working tree); the session hint defaults to "the invoking session" when +absent. Treat them independently — first identify and set aside the +session hint, then pass the rest to the diff resolution below. + +| `diff_description` | git command | Suggested label | |---|---|---| -| `/askdiff-dev` (no args) | working tree — see Step 2 | `Working tree` | -| `/askdiff-dev last commit` | `git diff HEAD~1 HEAD` | `HEAD~1..HEAD` | -| `/askdiff-dev last 3 commits` | `git diff HEAD~3 HEAD` | `HEAD~3..HEAD` | -| `/askdiff-dev the 5th latest commit` | `git diff HEAD~5 HEAD~4` | `HEAD~5..HEAD~4` | -| `/askdiff-dev current branch against feature/test` | `git diff feature/test...HEAD` (three-dot, PR semantics) | `feature/test…HEAD` | -| `/askdiff-dev main vs my branch` | `git diff main...HEAD` | `main…HEAD` | -| `/askdiff-dev abc123 vs def456` | `git diff abc123 def456` | `abc123..def456` | -| `/askdiff-dev staged` | `git diff --cached` | `staged` | +| (empty) | working tree — see Step 2 | `Working tree` | +| `last commit` | `git diff HEAD~1 HEAD` | `HEAD~1..HEAD` | +| `last 3 commits` | `git diff HEAD~3 HEAD` | `HEAD~3..HEAD` | +| `the 5th latest commit` | `git diff HEAD~5 HEAD~4` | `HEAD~5..HEAD~4` | +| `current branch against feature/test` | `git diff feature/test...HEAD` (three-dot, PR semantics) | `feature/test…HEAD` | +| `main vs my branch` | `git diff main...HEAD` | `main…HEAD` | +| `abc123 vs def456` | `git diff abc123 def456` | `abc123..def456` | +| `staged` | `git diff --cached` | `staged` | Defaults when the user is ambiguous: - "branch X against branch Y" / "X vs Y" between two named refs ⇒ three-dot @@ -104,6 +116,48 @@ each ref the user named directly. If any fails, stop and tell the user which ref didn't resolve — do not launch the server. (Refs returned by the search ladder are already validated by virtue of `git log` finding them.) +### Session hint (optional) + +By default `/askdiff-dev` attaches the WS server to the **invoking** +session (the one running this skill). The user may override that by +carrying a phrase about the target session in their input. Decompose the +input into two parts: + +- `diff_description` — what to diff (everything Step 1's table/ladder uses) +- `session_hint` — one of `none`, `explicit-id `, or + `keywords ` + +**Trigger phrases** for `session_hint` (illustrative — generalize from +these): + +- "attached to (the/a) session …" +- "connected to (the/a) session/conversation/chat …" +- "in (the/a/our) session [about / where / that] …" +- "from (the/a) [chat / conversation] [where / about] …" +- "the session in which …", "session that …" +- "session id ``", "session ``", or a bare UUID-shaped token + (8+ hex chars, optionally with dashes) + +**Examples:** + +| User input | `diff_description` | `session_hint` | +|---|---|---| +| `last commit` | `last commit` | none | +| (empty) | (working tree) | none | +| `the staleness commit attached to the session where we discussed mtime checks` | `the staleness commit` | keywords: "mtime checks" | +| `last commit in our session about pricing rules and tax math` | `last commit` | keywords: "pricing rules", "tax math" | +| `session 322bc90a` | (working tree) | explicit-id: `322bc90a` | +| `abc123 vs def456 in session 322bc90a-714f-41b7-914e-109404e46072` | `abc123 vs def456` | explicit-id: full UUID | + +**Be conservative.** If parsing is itself ambiguous (e.g. "the foo session" +— is "session" a noun in the diff or a trigger?), treat the whole input +as `diff_description` (no session hint). Don't ask the user to clarify the +parse — just resolve the diff and proceed; the default attachment to the +invoking session is always safe. + +The session hint is consumed in Step 4. Steps 2 and 3 use only +`diff_description`. + ## Step 2 — write the diff to a session-stable file First resolve the parent Claude Code session and project cwd. All `/tmp` @@ -160,7 +214,7 @@ show "No changes." **Mark the diff as volatile if you took the working-tree path.** Set `volatile=1` if Step 2 used the working-tree block (the diff can drift as the user keeps editing); set `volatile=0` for description-based diffs -(immutable git history). Step 4 forwards this to the server as +(immutable git history). Step 5 forwards this to the server as `ASKDIFF_DIFF_VOLATILE`, which gates the per-file mtime staleness check. ## Step 3 — pick a short label @@ -168,7 +222,147 @@ the user keeps editing); set `volatile=0` for description-based diffs Use the "Suggested label" column above. For the working-tree case, use `Working tree`. Keep it under ~40 chars. This becomes `ASKDIFF_DIFF_LABEL`. -## Step 4 — launch (in-repo) +## Step 4 — resolve the target session + +Compute `attached_session` and `session_source` from `session_hint` +(captured in Step 1). The default is the invoking session — that path +matches today's behavior and skips all the matching logic below. + +```bash +attached_session="$session_id" # default = invoking +session_source="invoking" + +sessions_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/$(echo "$project_cwd" | tr '/' '-')" +``` + +### 4a. No hint → invoking session (default) + +If `session_hint` is `none`, leave the defaults and skip to Step 5. + +### 4b. Explicit ID → resolve + +If `session_hint` is `explicit-id `: + +```bash +explicit_id="" + +if echo "$explicit_id" | grep -qE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then + # Full UUID: trust it if the file exists. + if [ -f "$sessions_dir/$explicit_id.jsonl" ]; then + attached_session="$explicit_id" + session_source="explicit" + else + # → AskUserQuestion: session not found, use current? + : + fi +else + # Short prefix: glob and disambiguate. + shopt -s nullglob + matches=( "$sessions_dir/${explicit_id}"*.jsonl ) + shopt -u nullglob + case ${#matches[@]} in + 1) + attached_session=$(basename "${matches[0]}" .jsonl) + session_source="explicit" + ;; + 0) + # → AskUserQuestion: no session matches "", use current? + : ;; + *) + # → AskUserQuestion: pick one of N candidates (list short-uuid · age) + : ;; + esac +fi +``` + +For the AskUserQuestion branches above: + +- **Not found / 0 matches**: options are "Use current session" and "Cancel" (do not launch). +- **Multiple prefix matches**: one option per UUID labelled ` · ` (compute age below), plus "Use current session". Set `attached_session` and `session_source="explicit"` from the user's pick. + +### 4c. Keywords → grep, decide, possibly ask + +If `session_hint` is `keywords `: + +```bash +needles_file=$(mktemp) + +# 1. The user's session keywords (literal phrases — one per line). +printf '%s\n' "" "" >> "$needles_file" + +# 2. Changed file paths from the resolved diff (additional signal, +# catches sessions that Read/Edit/Write'd those files). +command grep -E '^\+\+\+ b/' "$diff_file" | sed -E 's|^\+\+\+ b/||' >> "$needles_file" + +# 3. Commit SHAs (only for description-based diffs — Claude knows these +# from Step 1's resolution). Skip for working-tree diffs. +for sha in "" ""; do + [ -n "$sha" ] && printf '%s\n' "$sha" >> "$needles_file" +done + +# 4. Branch names (only for the X...Y / X..Y form). +for br in "" ""; do + [ -n "$br" ] && printf '%s\n' "$br" >> "$needles_file" +done + +# Search recent JSONLs (mtime −30d), filter out the invoking session +# (it always matches its own JSONL because the user just typed the +# keywords into it), return at most 5 rows of " " sorted +# by hit count desc. +# +# Three subtle things below — change them at your peril: +# - `command grep` bypasses any shell function/alias that wraps grep. +# Claude Code's harness wraps grep as a function that proxies to +# ugrep with extra flags, and that wrapper breaks `-Ff `. +# - We pipe `find` directly into `while read`, instead of `for f in +# $(find ...)`. zsh doesn't word-split unquoted variable expansions +# on newlines by default; that for-loop iterates exactly ONCE with +# $f containing every path concatenated. +# - `count=$(grep -c ...)` then `[ -z "$count" ] && count=0` — do NOT +# write `count=$(grep -c ... || echo 0)`. grep -c always prints a +# number (0 on no match) AND exits non-zero when there are no +# matches, so the `|| echo 0` doubles the output to "0\n0" and +# breaks the numeric `-gt` comparison. +results=$( + find "$sessions_dir" -name '*.jsonl' -mtime -30 -type f 2>/dev/null \ + | while read -r f; do + uuid=$(basename "$f" .jsonl) + [ "$uuid" = "$session_id" ] && continue + count=$(command grep -cFf "$needles_file" "$f" 2>/dev/null) + [ -z "$count" ] && count=0 + [ "$count" -gt 0 ] && echo "$count $uuid" + done | sort -rn | head -5 +) +rm -f "$needles_file" +``` + +Read `$results` and route: + +| Result shape | Action | +|---|---| +| 0 lines | AskUserQuestion: "no session matched ``. Use current session?" → "Use current" or "Cancel and refine" | +| 1 line | use that UUID; `attached_session=$uuid`, `session_source="matched"` | +| 2+ lines, top count ≥ 2× second | use top-1; `session_source="matched"` | +| 2–5 lines, comparable counts | AskUserQuestion: list each candidate as ` · · hits`, plus "Use current session" | + +For ages (used in AskUserQuestion labels): + +```bash +now=$(date +%s) +mtime=$(stat -f %m "$sessions_dir/$uuid.jsonl" 2>/dev/null || stat -c %Y "$sessions_dir/$uuid.jsonl") +age_sec=$(( now - mtime )) +if [ $age_sec -lt 86400 ]; then + age_str="$((age_sec / 3600))h ago" +else + age_str="$((age_sec / 86400))d ago" +fi +``` + +**Don't widen the search automatically.** If results are empty or +unclear, surface that to the user via AskUserQuestion. Re-run with +broader scope (e.g. `mtime -90`) only if the user explicitly says to. + +## Step 5 — launch (in-repo) Run as a single Bash command so the discovered values survive into the launch. Substitute `EXTRA_DIFF_FILE` and `EXTRA_DIFF_LABEL` literally with @@ -220,7 +414,7 @@ fi # 3. Start the WS server (in-repo via tsx). cd "$project_cwd" \ && PORT=$port \ - ASKDIFF_SESSION_ID="$session_id" \ + ASKDIFF_SESSION_ID="$attached_session" \ ASKDIFF_PROJECT_CWD="$project_cwd" \ ASKDIFF_DIFF_FILE="$EXTRA_DIFF_FILE" \ ASKDIFF_DIFF_LABEL="$EXTRA_DIFF_LABEL" \ @@ -250,8 +444,9 @@ if ! $ui_running; then fi # 5. Wait for Vite to print its "Local: http://localhost:XXXX/" line. +# (`command grep` bypasses the harness's grep wrapper — see Step 4c.) for _ in $(seq 1 60); do - grep -q "Local:" "$ui_log" 2>/dev/null && break + command grep -q "Local:" "$ui_log" 2>/dev/null && break sleep 0.25 done @@ -279,7 +474,11 @@ echo "WS PID: $new_pid (saved to $pid_file)" Then tell the user: - the WS server port (visible in the `listening on ws://...` line) -- the resolved Claude session ID (from the `claude session:` line) +- the resolved Claude session ID (from the `claude session:` line) — and + if `$session_source` is `explicit` or `matched`, say so explicitly + (e.g. "attached to matched session 322bc90a (was: invoking)") so the + user knows their asks are not landing in the current session's + transcript - the diff label (always set) - the WS log file (printed as the `WS log:` line — `/tmp/askdiff..log`) - the Vite log file (printed as the `UI log:` line — `/tmp/askdiff-ui..log`) diff --git a/.claude/skills/askdiff/SKILL.md b/.claude/skills/askdiff/SKILL.md index 04be5f5..ab43ec2 100644 --- a/.claude/skills/askdiff/SKILL.md +++ b/.claude/skills/askdiff/SKILL.md @@ -11,26 +11,38 @@ file. The server is intentionally git-illiterate — it only reads the file you produce and serves it to the browser. Skipping the file is a startup error. -> **Keep Step 1–3 in sync with `.claude/skills/askdiff-dev/SKILL.md`.** The -> diff-resolution flow (interpret → git → temp file → label) must behave -> identically in both skills; only Step 4 (launch) differs. If you change -> the table or the bash blocks below, change them in the dev skill too. +> **Keep Steps 1–4 in sync with `.claude/skills/askdiff-dev/SKILL.md`.** The +> diff-resolution and session-resolution flow (interpret → git → temp file +> → label → pick session) must behave identically in both skills; only +> Step 5 (launch) differs. If you change any block below — including the +> session-resolution logic in Step 4 — change it in the dev skill too. -## Step 1 — figure out which diff the user wants +## Step 1 — figure out which diff the user wants (and which session) -Look at the message that invoked this skill. Anything after `/askdiff` is the -user's diff description (may be empty). +Look at the message that invoked this skill. Anything after `/askdiff` is +free-form natural language that may carry **two** kinds of information: -| User said | git command | Suggested label | +1. A **diff description** — what to diff (handled by the table/ladder + below). This part is what Step 2 turns into a `git diff` command. +2. An optional **session hint** — which Claude session to attach to + (handled by the *Session hint* subsection at the end of this step, + then resolved in Step 4). + +Either or both may be empty. The diff-description part may be empty +(working tree); the session hint defaults to "the invoking session" when +absent. Treat them independently — first identify and set aside the +session hint, then pass the rest to the diff resolution below. + +| `diff_description` | git command | Suggested label | |---|---|---| -| `/askdiff` (no args) | working tree — see Step 2 | `Working tree` | -| `/askdiff last commit` | `git diff HEAD~1 HEAD` | `HEAD~1..HEAD` | -| `/askdiff last 3 commits` | `git diff HEAD~3 HEAD` | `HEAD~3..HEAD` | -| `/askdiff the 5th latest commit` | `git diff HEAD~5 HEAD~4` | `HEAD~5..HEAD~4` | -| `/askdiff current branch against feature/test` | `git diff feature/test...HEAD` (three-dot, PR semantics) | `feature/test…HEAD` | -| `/askdiff main vs my branch` | `git diff main...HEAD` | `main…HEAD` | -| `/askdiff abc123 vs def456` | `git diff abc123 def456` | `abc123..def456` | -| `/askdiff staged` | `git diff --cached` | `staged` | +| (empty) | working tree — see Step 2 | `Working tree` | +| `last commit` | `git diff HEAD~1 HEAD` | `HEAD~1..HEAD` | +| `last 3 commits` | `git diff HEAD~3 HEAD` | `HEAD~3..HEAD` | +| `the 5th latest commit` | `git diff HEAD~5 HEAD~4` | `HEAD~5..HEAD~4` | +| `current branch against feature/test` | `git diff feature/test...HEAD` (three-dot, PR semantics) | `feature/test…HEAD` | +| `main vs my branch` | `git diff main...HEAD` | `main…HEAD` | +| `abc123 vs def456` | `git diff abc123 def456` | `abc123..def456` | +| `staged` | `git diff --cached` | `staged` | Defaults when the user is ambiguous: - "branch X against branch Y" / "X vs Y" between two named refs ⇒ three-dot @@ -99,6 +111,48 @@ each ref the user named directly. If any fails, stop and tell the user which ref didn't resolve — do not launch the server. (Refs returned by the search ladder are already validated by virtue of `git log` finding them.) +### Session hint (optional) + +By default `/askdiff` attaches the WS server to the **invoking** session +(the one running this skill). The user may override that by carrying a +phrase about the target session in their input. Decompose the input into +two parts: + +- `diff_description` — what to diff (everything Step 1's table/ladder uses) +- `session_hint` — one of `none`, `explicit-id `, or + `keywords ` + +**Trigger phrases** for `session_hint` (illustrative — generalize from +these): + +- "attached to (the/a) session …" +- "connected to (the/a) session/conversation/chat …" +- "in (the/a/our) session [about / where / that] …" +- "from (the/a) [chat / conversation] [where / about] …" +- "the session in which …", "session that …" +- "session id ``", "session ``", or a bare UUID-shaped token + (8+ hex chars, optionally with dashes) + +**Examples:** + +| User input | `diff_description` | `session_hint` | +|---|---|---| +| `last commit` | `last commit` | none | +| (empty) | (working tree) | none | +| `the staleness commit attached to the session where we discussed mtime checks` | `the staleness commit` | keywords: "mtime checks" | +| `last commit in our session about pricing rules and tax math` | `last commit` | keywords: "pricing rules", "tax math" | +| `session 322bc90a` | (working tree) | explicit-id: `322bc90a` | +| `abc123 vs def456 in session 322bc90a-714f-41b7-914e-109404e46072` | `abc123 vs def456` | explicit-id: full UUID | + +**Be conservative.** If parsing is itself ambiguous (e.g. "the foo session" +— is "session" a noun in the diff or a trigger?), treat the whole input +as `diff_description` (no session hint). Don't ask the user to clarify the +parse — just resolve the diff and proceed; the default attachment to the +invoking session is always safe. + +The session hint is consumed in Step 4. Steps 2 and 3 use only +`diff_description`. + ## Step 2 — write the diff to a session-stable file First resolve the parent Claude Code session and project cwd. All `/tmp` @@ -158,7 +212,7 @@ empty (clean tree); launch anyway and the UI will show "No changes." **Mark the diff as volatile if you took the working-tree path.** Set `volatile=1` if Step 2 used the working-tree block (the diff can drift as the user keeps editing); set `volatile=0` for description-based diffs -(immutable git history). Step 4 forwards this to the server as +(immutable git history). Step 5 forwards this to the server as `ASKDIFF_DIFF_VOLATILE`, which gates the per-file mtime staleness check. ## Step 3 — pick a short label @@ -166,7 +220,147 @@ the user keeps editing); set `volatile=0` for description-based diffs Use the "Suggested label" column above. For the working-tree case, use `Working tree`. Keep it under ~40 chars. This becomes `ASKDIFF_DIFF_LABEL`. -## Step 4 — launch +## Step 4 — resolve the target session + +Compute `attached_session` and `session_source` from `session_hint` +(captured in Step 1). The default is the invoking session — that path +matches today's behavior and skips all the matching logic below. + +```bash +attached_session="$session_id" # default = invoking +session_source="invoking" + +sessions_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/$(echo "$project_cwd" | tr '/' '-')" +``` + +### 4a. No hint → invoking session (default) + +If `session_hint` is `none`, leave the defaults and skip to Step 5. + +### 4b. Explicit ID → resolve + +If `session_hint` is `explicit-id `: + +```bash +explicit_id="" + +if echo "$explicit_id" | grep -qE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then + # Full UUID: trust it if the file exists. + if [ -f "$sessions_dir/$explicit_id.jsonl" ]; then + attached_session="$explicit_id" + session_source="explicit" + else + # → AskUserQuestion: session not found, use current? + : + fi +else + # Short prefix: glob and disambiguate. + shopt -s nullglob + matches=( "$sessions_dir/${explicit_id}"*.jsonl ) + shopt -u nullglob + case ${#matches[@]} in + 1) + attached_session=$(basename "${matches[0]}" .jsonl) + session_source="explicit" + ;; + 0) + # → AskUserQuestion: no session matches "", use current? + : ;; + *) + # → AskUserQuestion: pick one of N candidates (list short-uuid · age) + : ;; + esac +fi +``` + +For the AskUserQuestion branches above: + +- **Not found / 0 matches**: options are "Use current session" and "Cancel" (do not launch). +- **Multiple prefix matches**: one option per UUID labelled ` · ` (compute age below), plus "Use current session". Set `attached_session` and `session_source="explicit"` from the user's pick. + +### 4c. Keywords → grep, decide, possibly ask + +If `session_hint` is `keywords `: + +```bash +needles_file=$(mktemp) + +# 1. The user's session keywords (literal phrases — one per line). +printf '%s\n' "" "" >> "$needles_file" + +# 2. Changed file paths from the resolved diff (additional signal, +# catches sessions that Read/Edit/Write'd those files). +command grep -E '^\+\+\+ b/' "$diff_file" | sed -E 's|^\+\+\+ b/||' >> "$needles_file" + +# 3. Commit SHAs (only for description-based diffs — Claude knows these +# from Step 1's resolution). Skip for working-tree diffs. +for sha in "" ""; do + [ -n "$sha" ] && printf '%s\n' "$sha" >> "$needles_file" +done + +# 4. Branch names (only for the X...Y / X..Y form). +for br in "" ""; do + [ -n "$br" ] && printf '%s\n' "$br" >> "$needles_file" +done + +# Search recent JSONLs (mtime −30d), filter out the invoking session +# (it always matches its own JSONL because the user just typed the +# keywords into it), return at most 5 rows of " " sorted +# by hit count desc. +# +# Three subtle things below — change them at your peril: +# - `command grep` bypasses any shell function/alias that wraps grep. +# Claude Code's harness wraps grep as a function that proxies to +# ugrep with extra flags, and that wrapper breaks `-Ff `. +# - We pipe `find` directly into `while read`, instead of `for f in +# $(find ...)`. zsh doesn't word-split unquoted variable expansions +# on newlines by default; that for-loop iterates exactly ONCE with +# $f containing every path concatenated. +# - `count=$(grep -c ...)` then `[ -z "$count" ] && count=0` — do NOT +# write `count=$(grep -c ... || echo 0)`. grep -c always prints a +# number (0 on no match) AND exits non-zero when there are no +# matches, so the `|| echo 0` doubles the output to "0\n0" and +# breaks the numeric `-gt` comparison. +results=$( + find "$sessions_dir" -name '*.jsonl' -mtime -30 -type f 2>/dev/null \ + | while read -r f; do + uuid=$(basename "$f" .jsonl) + [ "$uuid" = "$session_id" ] && continue + count=$(command grep -cFf "$needles_file" "$f" 2>/dev/null) + [ -z "$count" ] && count=0 + [ "$count" -gt 0 ] && echo "$count $uuid" + done | sort -rn | head -5 +) +rm -f "$needles_file" +``` + +Read `$results` and route: + +| Result shape | Action | +|---|---| +| 0 lines | AskUserQuestion: "no session matched ``. Use current session?" → "Use current" or "Cancel and refine" | +| 1 line | use that UUID; `attached_session=$uuid`, `session_source="matched"` | +| 2+ lines, top count ≥ 2× second | use top-1; `session_source="matched"` | +| 2–5 lines, comparable counts | AskUserQuestion: list each candidate as ` · · hits`, plus "Use current session" | + +For ages (used in AskUserQuestion labels): + +```bash +now=$(date +%s) +mtime=$(stat -f %m "$sessions_dir/$uuid.jsonl" 2>/dev/null || stat -c %Y "$sessions_dir/$uuid.jsonl") +age_sec=$(( now - mtime )) +if [ $age_sec -lt 86400 ]; then + age_str="$((age_sec / 3600))h ago" +else + age_str="$((age_sec / 86400))d ago" +fi +``` + +**Don't widen the search automatically.** If results are empty or +unclear, surface that to the user via AskUserQuestion. Re-run with +broader scope (e.g. `mtime -90`) only if the user explicitly says to. + +## Step 5 — launch Run as a single Bash command. Substitute `EXTRA_DIFF_FILE` and `EXTRA_DIFF_LABEL` literally with the values from Step 2/3. @@ -179,8 +373,9 @@ set +e # this repo always pulls the newest published version). ASKDIFF_VERSION="latest" -# Filled in by Step 2/3 (session_id, project_cwd, suffix come from Step 2's -# preamble — keep that block above this one in your final invocation). +# Filled in by Steps 2/3/4 (session_id, project_cwd, suffix come from +# Step 2's preamble; attached_session and session_source come from +# Step 4 — keep both blocks above this one in your final invocation). EXTRA_DIFF_FILE="" EXTRA_DIFF_LABEL="" @@ -214,7 +409,7 @@ port_arg="" [ -n "$saved_port" ] && port_arg="--port $saved_port" cd "$project_cwd" \ - && ASKDIFF_SESSION_ID="$session_id" \ + && ASKDIFF_SESSION_ID="$attached_session" \ ASKDIFF_PROJECT_CWD="$project_cwd" \ ASKDIFF_DIFF_FILE="$EXTRA_DIFF_FILE" \ ASKDIFF_DIFF_LABEL="$EXTRA_DIFF_LABEL" \ @@ -223,9 +418,10 @@ cd "$project_cwd" \ new_pid=$! disown -# Wait for the listening line to land in the log. +# Wait for the listening line to land in the log. (`command grep` +# bypasses the harness's grep wrapper — see Step 4c for context.) for _ in $(seq 1 60); do - grep -q "listening on" "$log_file" 2>/dev/null && break + command grep -q "listening on" "$log_file" 2>/dev/null && break sleep 0.25 done @@ -277,7 +473,11 @@ fi The launch always happens — there's no halt-and-prompt branch anymore. Tell the user: - the WS server URL (the `listening on http://...` line) -- the resolved Claude session ID (the `claude session:` line) +- the resolved Claude session ID (the `claude session:` line) — and + if `$session_source` is `explicit` or `matched`, say so explicitly + (e.g. "attached to matched session 322bc90a (was: invoking)") so the + user knows their asks are not landing in the current session's + transcript - the diff label (always set) - the log file: `/tmp/askdiff.$suffix.log` (printed as the last `Log:` line) - the UI URL (last echoed `UI:` line) — opened on first launch; on a diff --git a/README.md b/README.md index af50f1c..e5dbd04 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ npx -y askdiff install-skill --global /askdiff last commit # HEAD~1..HEAD /askdiff main vs feature/x # main…HEAD (PR-style) /askdiff David's latest commit where he removed the xmpp integration # author + content search +/askdiff last commit attached to the session where we discussed auth # send asks to a different past session ``` That's it. No API key, no config. The browser opens to a syntax-highlighted diff; comments stream back as the model thinks. @@ -64,6 +65,10 @@ So your question becomes a real turn in the running session's transcript: Diff selection Describe which diff to review in plain English — working tree, last commit, branch comparisons, arbitrary refs. + + Session selection + By default asks flow into the invoking session. Add "in our session about X" or "session <uuid>" to attach to a different past session — the one that originally wrote the code, for example. + Inline comments Click the + gutter button to comment on any line. Drag to comment on a range. @@ -109,6 +114,31 @@ existing browser tab auto-reconnects on the same port. For working-tree diffs, an amber banner appears if any reviewed file has been edited since the diff was captured, prompting you to re-run `/askdiff`. +### Session selection + +By default `/askdiff` attaches to the **invoking** session — the one running +the skill. Asks become real turns in that session's transcript. + +If the diff you're reviewing was written (or investigated) in a *different* +past session, describe that session in natural language and asks will flow +there instead. The original "ask in the same session that wrote the code" +promise still holds — it's just that the session that wrote the code might +not be the session you're currently in: + +| You type | What attaches | +|---|---| +| `/askdiff last commit` | invoking session (default — same as today) | +| `/askdiff last commit in our session about pricing rules` | searches sessions in this project for "pricing rules" mentions; the dominant match attaches | +| `/askdiff abc123 vs def456 attached to the session that authored it` | Claude builds keyword needles from the diff; matches that to a past session | +| `/askdiff session 322bc90a` | exact UUID prefix; resolves to a specific session | +| `/askdiff in session 322bc90a-714f-41b7-914e-109404e46072` | full UUID | + +Search is bounded: only sessions touched in the last 30 days, top 5 candidates +by hit count, and `command grep -Ff` over the JSONL transcripts so it stays +fast and uses zero LLM tokens. If multiple sessions match comparably, askdiff +asks you which to use rather than guessing. If nothing matches, it falls back +to the invoking session. + ### Inline comments Hover any line in the diff. A `+` button appears in the gutter. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0c2ac19..6eec0ab 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -92,9 +92,7 @@ async function runServer(opts: RunOptions): Promise { console.log(`${PROJECT_NAME} server listening on ${url}`); console.log(` protocol: ${PROTOCOL_VERSION}`); console.log(` project: ${resolved.cwd}`); - console.log( - ` claude session: ${resolved.session ?? "(none — set ASKDIFF_SESSION_ID or use --session)"}`, - ); + console.log(` claude session: ${resolved.session}`); console.log(` diff file: ${resolved.diffFile}`); if (resolved.diffLabel) console.log(` diff label: ${resolved.diffLabel}`); if (resolved.volatile) console.log(` diff kind: volatile (staleness checks enabled)`); @@ -185,7 +183,7 @@ interface ResolvedOptions { port: number; host: string; open: boolean; - session: string | null; + session: string; cwd: string; diffFile: string; diffLabel?: string; @@ -204,8 +202,12 @@ async function resolveOptions(opts: RunOptions): Promise { const session = opts.session ?? process.env["ASKDIFF_SESSION_ID"] ?? - fromManifest?.sessionId ?? - null; + fromManifest?.sessionId; + if (!session) { + throw new Error( + "Claude Code session is required. Pass --session , set ASKDIFF_SESSION_ID, or run from inside a Claude Code session so the parent manifest is found.", + ); + } const port = opts.port ?? (Number(process.env["PORT"]) || DEFAULT_PORT); diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index 36a46b6..eb22d14 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -16,7 +16,6 @@ import { PROTOCOL_VERSION, ServerMessageSchema, SessionMessageSchema, - SetSessionMessageSchema, parseClientMessage, } from "./schemas"; @@ -93,21 +92,12 @@ describe("PingMessageSchema", () => { }); }); -describe("SetSessionMessageSchema", () => { - it("requires a non-empty session_id", () => { - expect(SetSessionMessageSchema.safeParse({ type: "set_session" }).success).toBe(false); - expect(SetSessionMessageSchema.safeParse({ type: "set_session", session_id: "" }).success).toBe(false); - expect(SetSessionMessageSchema.safeParse({ type: "set_session", session_id: "uuid" }).success).toBe(true); - }); -}); - describe("ClientMessageSchema (discriminated union)", () => { it.each([ ["ask", { type: "ask", id: "q1", file: "f", from_line: 0, to_line: 0, chunk: "", question: "?" }], ["cancel", { type: "cancel", id: "q1" }], ["diff_request", { type: "diff_request" }], ["ping", { type: "ping" }], - ["set_session", { type: "set_session", session_id: "x" }], ])("accepts %s", (_label, msg) => { expect(ClientMessageSchema.safeParse(msg).success).toBe(true); }); @@ -315,18 +305,10 @@ describe("PongMessageSchema", () => { }); describe("SessionMessageSchema", () => { - it("accepts a UUID session_id", () => { - expect( - SessionMessageSchema.safeParse({ type: "session", session_id: "abc" }).success, - ).toBe(true); - }); - - it("accepts an explicit null (no session configured)", () => { - expect(SessionMessageSchema.safeParse({ type: "session", session_id: null }).success).toBe(true); - }); - - it("rejects missing session_id field", () => { + it("requires a non-empty session_id", () => { expect(SessionMessageSchema.safeParse({ type: "session" }).success).toBe(false); + expect(SessionMessageSchema.safeParse({ type: "session", session_id: "" }).success).toBe(false); + expect(SessionMessageSchema.safeParse({ type: "session", session_id: "abc" }).success).toBe(true); }); }); @@ -338,7 +320,7 @@ describe("ServerMessageSchema (discriminated union)", () => { ["done", { type: "done", id: "q1" }], ["error", { type: "error", message: "x" }], ["pong", { type: "pong" }], - ["session", { type: "session", session_id: null }], + ["session", { type: "session", session_id: "abc" }], ])("accepts %s", (_label, msg) => { expect(ServerMessageSchema.safeParse(msg).success).toBe(true); }); diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 3695a06..e7339df 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -38,17 +38,11 @@ export const PingMessageSchema = z.object({ type: z.literal("ping"), }); -export const SetSessionMessageSchema = z.object({ - type: z.literal("set_session"), - session_id: z.string().min(1), -}); - export const ClientMessageSchema = z.discriminatedUnion("type", [ AskMessageSchema, CancelMessageSchema, DiffRequestMessageSchema, PingMessageSchema, - SetSessionMessageSchema, ]); export const HelloMessageSchema = z.object({ @@ -99,9 +93,12 @@ export const PongMessageSchema = z.object({ type: z.literal("pong"), }); +// Read-only — emitted once on connect so the UI can display "asks go to +// this session." There is no client-side counterpart: session attachment +// is locked in at server startup via ASKDIFF_SESSION_ID. export const SessionMessageSchema = z.object({ type: z.literal("session"), - session_id: z.string().nullable(), + session_id: z.string().min(1), }); export const ServerMessageSchema = z.discriminatedUnion("type", [ diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 3ea8d8a..66232fb 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -15,7 +15,6 @@ import type { PongMessageSchema, ServerMessageSchema, SessionMessageSchema, - SetSessionMessageSchema, } from "./schemas"; export type DiffHunk = z.infer; @@ -25,7 +24,6 @@ export type AskMessage = z.infer; export type CancelMessage = z.infer; export type DiffRequestMessage = z.infer; export type PingMessage = z.infer; -export type SetSessionMessage = z.infer; export type ClientMessage = z.infer; export type HelloMessage = z.infer; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index fb5df50..06fe25c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -10,15 +10,14 @@ import { ClaudeCliError, streamAnswer } from "./claude"; import { DiffError, getDiff } from "./util/diff"; import { checkStaleness } from "./util/staleness"; import { PROTOCOL_VERSION, parseClientMessage, type AskMessage } from "@askdiff/protocol"; -import { DEFAULT_HOST, DEFAULT_IDLE_SHUTDOWN_MS, PROJECT_NAME } from "./util/constants"; -import { isValidSessionId, sessionExists } from "./util/session"; -import { broadcast, send } from "./util/ws"; +import { DEFAULT_HOST, DEFAULT_IDLE_SHUTDOWN_MS } from "./util/constants"; +import { send } from "./util/ws"; export const WS_PATH = "/ws"; export interface ServerState { cwd: string; - claudeSessionId: string | null; + claudeSessionId: string; clients: Set; diffFile: string; diffLabel?: string; @@ -30,7 +29,7 @@ export interface ServerState { export interface StartServerOptions { cwd: string; - sessionId: string | null; + sessionId: string; // Path to a unified diff file the skill produced (e.g. via `git diff // HEAD~1 HEAD > $diffFile`). The server treats this as the single source // of truth — it never invokes git itself. @@ -90,15 +89,6 @@ async function handleAsk( send(ws, { type: "error", id: ask.id, message: `duplicate ask id: ${ask.id}` }); return; } - if (!state.claudeSessionId) { - send(ws, { - type: "error", - id: ask.id, - message: - `No Claude Code session configured. Start ${PROJECT_NAME} from inside a session, or send \`set_session\` with a valid session_id.`, - }); - return; - } const controller = new AbortController(); controllers.set(ask.id, controller); @@ -125,29 +115,6 @@ async function handleAsk( } } -async function handleSetSession( - ws: WebSocket, - state: ServerState, - requestedId: string, -): Promise { - if (!isValidSessionId(requestedId)) { - send(ws, { - type: "error", - message: `invalid session_id: ${requestedId} (expected UUID)`, - }); - return; - } - if (!(await sessionExists(state.cwd, requestedId))) { - send(ws, { - type: "error", - message: `session ${requestedId} not found under project ${state.cwd}`, - }); - return; - } - state.claudeSessionId = requestedId; - broadcast(state, { type: "session", session_id: requestedId }); -} - function errorMessage(err: unknown): string { if (err instanceof ClaudeCliError) return err.message; if (err instanceof DiffError) return err.message; @@ -218,6 +185,8 @@ export async function startServer(opts: StartServerOptions): Promise { ); console.log(` protocol: ${PROTOCOL_VERSION}`); console.log(` project: ${cwd}`); - console.log( - ` claude session: ${initialSession ?? "(none — send set_session before asking)"}`, - ); + console.log(` claude session: ${initialSession}`); console.log(` diff file: ${diffFile}`); if (diffLabel) console.log(` diff label: ${diffLabel}`); if (volatile) console.log(` diff kind: volatile (staleness checks enabled)`); diff --git a/packages/server/src/util/session.test.ts b/packages/server/src/util/session.test.ts index 41f17b2..02a810c 100644 --- a/packages/server/src/util/session.test.ts +++ b/packages/server/src/util/session.test.ts @@ -98,8 +98,10 @@ describe("session-resolution filesystem helpers", () => { }); describe("resolveInitialSessionId", () => { - it("returns null when env is unset (server starts unconfigured)", async () => { - await expect(resolveInitialSessionId(projectCwd)).resolves.toBeNull(); + it("throws when env is unset (server requires a session)", async () => { + await expect(resolveInitialSessionId(projectCwd)).rejects.toThrow( + /ASKDIFF_SESSION_ID is required/, + ); }); it("returns the env session when valid and present on disk", async () => { diff --git a/packages/server/src/util/session.ts b/packages/server/src/util/session.ts index b55ae8b..0e9469d 100644 --- a/packages/server/src/util/session.ts +++ b/packages/server/src/util/session.ts @@ -24,9 +24,13 @@ export const sessionExists = async (cwd: string, sessionId: string): Promise => { +export const resolveInitialSessionId = async (cwd: string): Promise => { const envSession = process.env[`${PROJECT_NAME_UPPER_CASE}_SESSION_ID`]; - if (!envSession) return null; + if (!envSession) { + throw new Error( + `${PROJECT_NAME_UPPER_CASE}_SESSION_ID is required. The askdiff skill always sets it; if you're running the server by hand, set the env var or pass --session.`, + ); + } // sessionExists throws on malformed UUID; here we additionally treat // "valid UUID but no JSONL on disk" as a hard startup failure so the diff --git a/packages/ui-browser/src/components/CommentWidget.tsx b/packages/ui-browser/src/components/CommentWidget.tsx index 0723c51..6b37a20 100644 --- a/packages/ui-browser/src/components/CommentWidget.tsx +++ b/packages/ui-browser/src/components/CommentWidget.tsx @@ -21,7 +21,6 @@ export const CommentWidget = ({ file, fromLine, toLine, chunk, asks }: Props) => const [draft, setDraft] = useState(""); const startAsk = useStore((s) => s.startAsk); const closeComposer = useStore((s) => s.closeComposer); - const sessionId = useStore((s) => s.sessionId); const conn = useStore((s) => s.conn); const textareaRef = useRef(null); @@ -35,8 +34,7 @@ export const CommentWidget = ({ file, fromLine, toLine, chunk, asks }: Props) => if (!hasAsksOnMount.current) textareaRef.current?.focus(); }, []); - const canSend = - draft.trim().length > 0 && sessionId !== null && conn.state === "open"; + const canSend = draft.trim().length > 0 && conn.state === "open"; const submit = () => { const question = draft.trim(); @@ -89,11 +87,6 @@ export const CommentWidget = ({ file, fromLine, toLine, chunk, asks }: Props) => }} />
- {sessionId === null && ( - - Configure a Claude session to send asks. - - )} - - -
-
-

Claude session

-

- Set the Claude Code session UUID this UI should attach to. - Asks append to that session's transcript. -

-
- {sessionId !== null && ( -
- {sessionId} -
- )} -
- { - setDraft(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === "Enter") apply(); - }} - /> - -
-
-
- - ); -}; diff --git a/packages/ui-browser/src/components/TopBar.tsx b/packages/ui-browser/src/components/TopBar.tsx index 8a22265..7dd6a0d 100644 --- a/packages/ui-browser/src/components/TopBar.tsx +++ b/packages/ui-browser/src/components/TopBar.tsx @@ -1,6 +1,5 @@ import { useStore } from "@/lib/store"; import { Badge } from "@/components/ui/badge"; -import { SessionMenu } from "./SessionMenu"; import { ThemeToggle } from "./ThemeToggle"; const ConnectionDot = () => { @@ -19,10 +18,16 @@ const ConnectionDot = () => { ); }; +// Truncate to a git-short-sha-style ID. Full UUID lives in the badge's +// title attribute for hover/copy. +const truncSession = (sid: string) => + sid.length > 12 ? `${sid.slice(0, 4)}…${sid.slice(-4)}` : sid; + export const TopBar = () => { const project = useStore((s) => s.project); const protocol = useStore((s) => s.protocol); const diffLabel = useStore((s) => s.diff?.label); + const sessionId = useStore((s) => s.sessionId); // Always advertise *what* the user is reviewing — when the skill didn't // pre-compute a diff, the server falls back to the working tree, so that @@ -50,9 +55,17 @@ export const TopBar = () => {
)}
+ {sessionId && ( + + session {truncSession(sessionId)} + + )} -
); diff --git a/packages/ui-browser/src/lib/store.ts b/packages/ui-browser/src/lib/store.ts index 314c187..6449ffe 100644 --- a/packages/ui-browser/src/lib/store.ts +++ b/packages/ui-browser/src/lib/store.ts @@ -37,6 +37,9 @@ type Store = { conn: ConnectionState; protocol?: string; project?: string; + // The Claude Code session this UI is attached to. Read-only — set + // once when the server's `session` message arrives, never mutated + // from the UI (there's no protocol surface for that anymore). sessionId: string | null; // diff @@ -79,7 +82,7 @@ type Store = { setConn: (c: ConnectionState) => void; setProtocol: (p: string) => void; setProject: (p: string) => void; - setSessionId: (sid: string | null) => void; + setSessionId: (sid: string) => void; setDiff: ( raw: string, files: DiffFile[], @@ -106,7 +109,6 @@ type Store = { closeComposer: (file: string, fromLine: number) => void; startAsk: (input: AskInput) => string; cancel: (askId: string) => void; - setSession: (sid: string) => void; }; const pickKnown = ( @@ -293,8 +295,4 @@ export const useStore = create((set, get) => ({ asks: { ...s.asks, [askId]: { ...ask, status: "cancelled" } }, })); }, - - setSession: (sid) => { - get()._send({ type: "set_session", session_id: sid }); - }, }));