diff --git a/.claude/skills/askdiff-dev/SKILL.md b/.claude/skills/askdiff-dev/SKILL.md index 207da27..f46eee5 100644 --- a/.claude/skills/askdiff-dev/SKILL.md +++ b/.claude/skills/askdiff-dev/SKILL.md @@ -5,22 +5,17 @@ user-invocable: true allowed-tools: Bash --- -Local-development variant of `/askdiff`. Starts the WS server **and** the -browser UI's Vite dev server (with HMR), and exercises the in-repo -TypeScript instead of the published npm package. Vite is configured to -proxy `/ws` to the WS server, so the UI uses the same same-origin -`new WebSocket('ws://host/ws')` URL in dev as in prod. The -`ASKDIFF_DEV_WS_TARGET` env var tells Vite which port to forward to. - -Use this when editing `packages/server` or `packages/ui-browser` and you -want changes to reload instantly instead of rebuilding/republishing. - -> **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. +Local-dev variant of `/askdiff`. Starts the WS server **and** Vite (HMR) +against in-repo TypeScript instead of the published CLI. Vite proxies +`/ws` to the WS server (port from `ASKDIFF_DEV_WS_TARGET`), so the UI +uses the same same-origin `new WebSocket('ws://host/ws')` URL as in prod. + +Use when editing `packages/server` or `packages/ui-browser` for instant +reload instead of rebuild/republish. + +> **Keep Steps 1–4 in sync with `.claude/skills/askdiff/SKILL.md`** — only +> the Step 4c `resolve-session` invocation and Step 5 launch differ +> between the two skills. ## Step 1 — figure out which diff the user wants (and which session) @@ -58,13 +53,12 @@ Defaults when the user is ambiguous: ### When the description is vague -If the description doesn't fit the table — e.g. "the commit where I added -the favicon", "the last commit by my coworker David", "where we ripped out -the old auth code", "the commit that broke CI last week" — pin down a -single commit with the ladder below, then diff `^..` (same shape -as the "Nth latest commit" pattern). Try in order until exactly one commit -matches; if several match, pick the most recent and **tell the user which -one you chose**; if none match, stop and ask — do not guess. +If the description doesn't fit the table (e.g. "the commit where I added +the favicon", "where we ripped out the old auth"), pin down a single +commit with the ladder below, then diff `^..`. Try in order +until exactly one commit matches; if several match, pick the most recent +and **tell the user which one you chose**; if none match, stop and ask — +do not guess. 1. **Author.** "by ", "'s last", "by my coworker": ```bash @@ -100,16 +94,12 @@ previous commit, where I added a favicon" but the favicon is at HEAD~2), trust the description over the count and **flag the off-by-one to the user** so they know what you picked. -**Stay within git — never read file contents to disambiguate.** The four -steps above use git only: `git log` (with `--author`, `--grep`, `-S`, -`-G`, `--follow`, `--diff-filter`) and `git ls-files | grep` on -path names. **Do not** `cat`/`head` files, **do not** `grep -r` or `rg` -into working-tree contents, **do not** Read the contents of candidate -files. If the four-step ladder doesn't pin down a unique commit, **stop -and ask the user via `AskUserQuestion`** — surface the candidates the -ladder turned up and let the user pick, or ask for a more specific -description. Reading file contents during search is a token-cost cliff -that requires explicit user consent. +**Stay within git — never read file contents to disambiguate.** Use only +`git log` (with `--author`, `--grep`, `-S`, `-G`, `--follow`, +`--diff-filter`) and `git ls-files | grep` on path names. Don't `cat`, +`grep -r`, `rg`, or `Read` working-tree contents. If the ladder doesn't +pin down a unique commit, AskUserQuestion with the candidates — reading +files during search is a token-cost cliff that requires user consent. **Validate every ref first.** Run `git rev-parse --verify ^{commit}` for each ref the user named directly. If any fails, stop and tell the user @@ -127,16 +117,10 @@ input into two parts: - `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) +A session hint shows up as language about the *session/conversation/chat* +the diff comes from — e.g. "attached to the session …", "in our session +about X", "from the chat where Y", "session id ``", or a bare +UUID-shaped token (8+ hex chars). **Examples:** @@ -211,11 +195,8 @@ user the requested diff is empty and don't launch. The working-tree path *can* legitimately be 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 5 forwards this to the server as -`ASKDIFF_DIFF_VOLATILE`, which gates the per-file mtime staleness check. +Set `volatile=1` for the working-tree path, `volatile=0` otherwise — Step 5 +forwards this as `ASKDIFF_DIFF_VOLATILE` (gates per-file mtime staleness). ## Step 3 — pick a short label @@ -225,8 +206,7 @@ Use the "Suggested label" column above. For the working-tree case, use ## 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. +(from Step 1). Default is the invoking session. ```bash attached_session="$session_id" # default = invoking @@ -256,14 +236,20 @@ if echo "$explicit_id" | grep -qE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f] : fi else - # Short prefix: glob and disambiguate. - shopt -s nullglob - matches=( "$sessions_dir/${explicit_id}"*.jsonl ) - shopt -u nullglob + # Short prefix: list and disambiguate via `find` (zsh-compatible — see + # footgun in CLAUDE.md: `shopt` is bash-only and silently fails in zsh, + # and zsh arrays are 1-indexed so `${matches[0]}` returns empty). + matches=() + while IFS= read -r f; do + [ -n "$f" ] && matches+=("$f") + done < <(find "$sessions_dir" -maxdepth 1 -name "${explicit_id}*.jsonl" -type f 2>/dev/null) case ${#matches[@]} in 1) - attached_session=$(basename "${matches[0]}" .jsonl) - session_source="explicit" + # Iterate to dodge bash-vs-zsh first-index difference. + for f in "${matches[@]}"; do + attached_session=$(basename "$f" .jsonl) + session_source="explicit" + done ;; 0) # → AskUserQuestion: no session matches "", use current? @@ -275,92 +261,51 @@ else 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. +AskUserQuestion branches: 0 matches → "Use current session" or "Cancel" +(don't launch). Multiple matches → one option per UUID as +` · ` plus "Use current session"; on user pick set +`attached_session` and `session_source="explicit"`. -### 4c. Keywords → grep, decide, possibly ask +### 4c. Keywords → resolve-session, decide, possibly ask -If `session_hint` is `keywords `: +If `session_hint` is `keywords `, call the in-repo CLI's +`resolve-session` subcommand. It searches recent project JSONLs (mtime +−30d, excluding the invoking session, top 5 by hit count) and prints +single-line JSON: `{"candidates":[{"uuid":"…","count":N,"age":"…"}, …]}`. ```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 + pnpm --filter askdiff exec tsx src/index.ts resolve-session \ + --cwd "$project_cwd" \ + --invoking "$session_id" \ + --diff-file "$diff_file" \ + --keyword "" \ + --keyword "" \ + --sha "" \ + --sha "" \ + --branch "" \ + --branch "" ) -rm -f "$needles_file" +echo "$results" ``` -Read `$results` and route: +Repeat `--keyword`/`--sha`/`--branch` per value; omit a flag entirely +if its list is empty. Always pass `--diff-file` (changed file paths feed +the search as additional needles regardless of diff source); omit `--sha` +and `--branch` for working-tree diffs (no commit/branch context). -| 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): +Read `$results` and route on `.candidates`: -```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 -``` +| Result | Action | +|---|---| +| empty | AskUserQuestion: "no session matched ``. Use current?" → "Use current" or "Cancel and refine" | +| 1 candidate | use that UUID; `attached_session=$uuid`, `session_source="matched"` | +| 2+, top count ≥ 2× second | use top-1; `session_source="matched"` | +| 2–5, comparable counts | AskUserQuestion: one option per candidate as ` · · hits`, plus "Use current session" | -**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. +**Don't widen scope automatically** (e.g. by raising `--max-age-days` or +`--top`). Surface 0/unclear results via AskUserQuestion; re-run only on +user request. ## Step 5 — launch (in-repo) @@ -381,11 +326,9 @@ ui_log="/tmp/askdiff-ui.$suffix.log" ui_pid_file="/tmp/askdiff-ui.$suffix.pid" pid_file="/tmp/askdiff.$suffix.pid" -# 1. If a server for this session is already running, kill it and remember -# its port. Reusing the port matters here especially: Vite's /ws proxy -# (ASKDIFF_DEV_WS_TARGET) is locked to whatever port we passed when -# Vite first started. Reusing keeps the browser tab alive — its WS -# will auto-reconnect (see lib/ws.ts) and load the new diff. +# 1. Kill any previous server for this session and reuse its port — +# Vite's /ws proxy is locked to whatever port we passed when Vite +# first started, so reusing keeps the open browser tab valid. saved_port="" if [ -f "$pid_file" ]; then read -r old_pid saved_port < "$pid_file" 2>/dev/null @@ -443,8 +386,8 @@ if ! $ui_running; then disown fi -# 5. Wait for Vite to print its "Local: http://localhost:XXXX/" line. -# (`command grep` bypasses the harness's grep wrapper — see Step 4c.) +# 5. Wait for Vite's "Local: http://localhost:XXXX/" line. +# (`command grep` — see Step 4c footguns.) for _ in $(seq 1 60); do command grep -q "Local:" "$ui_log" 2>/dev/null && break sleep 0.25 @@ -455,42 +398,28 @@ vite_port=$(sed -E -n 's|.*Local:[^0-9]*([0-9]+)/?.*|\1|p' "$ui_log" | head -1) ui_url="http://localhost:${vite_port}/" -# Only auto-open the browser on the *first* launch (no saved_port). On -# refresh-style re-invocations, the user's tab is still open and its WS -# will auto-reconnect; opening another tab would be annoying. +# Auto-open only on first launch — refresh re-invocations have a tab open. if [ -z "$saved_port" ]; then (open "$ui_url" >/dev/null 2>&1 || xdg-open "$ui_url" >/dev/null 2>&1) & fi echo "" -if [ -n "$saved_port" ]; then - echo "Refreshed: same port, new diff. Browser tab will auto-reconnect." -fi +[ -n "$saved_port" ] && echo "Refreshed: same port, new diff. Browser tab will auto-reconnect." echo "UI: $ui_url" echo "WS log: $log_file" echo "UI log: $ui_log" 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) — 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`) -- the UI URL (last echoed `UI:` line) — already opened in their default browser - -If the `claude session:` line says `(none ...)`, the parent CC manifest was -not found at `$session_file`. That usually means the server was launched -from outside a Claude Code session. - -The WS server idle-shuts after 5 min with no connected clients (see -`ASKDIFF_IDLE_SHUTDOWN_MS`); re-invoking `/askdiff-dev` always kills the -previous WS server for this session before starting a new one. Vite -intentionally stays running across re-invocations (HMR is the whole -point) — kill it via Activity Monitor or `pkill -f 'ui-browser.*vite'` -on the rare occasion you want it gone. +The user already sees `UI:` / `WS log:` / `UI log:` / `WS PID:` / +`Refreshed:` in the bash output. After launch, only narrate: + +- if `$session_source` is `explicit` or `matched`, say so (e.g. + "attached to matched session 322bc90a (was: invoking)") so the user + knows asks aren't landing in the current session's transcript + +The WS server idle-shuts after 5 min with no connected clients; +re-invoking `/askdiff-dev` kills the previous WS server for this session +before starting a new one. Vite stays running across re-invocations +(HMR is the whole point) — kill it via Activity Monitor or +`pkill -f 'ui-browser.*vite'` if needed. diff --git a/.claude/skills/askdiff/SKILL.md b/.claude/skills/askdiff/SKILL.md index ab43ec2..ec552d1 100644 --- a/.claude/skills/askdiff/SKILL.md +++ b/.claude/skills/askdiff/SKILL.md @@ -5,17 +5,12 @@ user-invocable: true allowed-tools: Bash --- -Compute the unified diff the user wants to review, write it to a temp file, -then launch the published `askdiff` CLI in the background pointing at that -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 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. +Compute the user's diff, write it to a temp file, then launch the +`askdiff` CLI in the background pointing at that file. + +> **Keep Steps 1–4 in sync with `.claude/skills/askdiff-dev/SKILL.md`** — +> only the Step 4c `resolve-session` invocation and Step 5 launch differ +> between the two skills. ## Step 1 — figure out which diff the user wants (and which session) @@ -53,13 +48,12 @@ Defaults when the user is ambiguous: ### When the description is vague -If the description doesn't fit the table — e.g. "the commit where I added -the favicon", "the last commit by my coworker David", "where we ripped out -the old auth code", "the commit that broke CI last week" — pin down a -single commit with the ladder below, then diff `^..` (same shape -as the "Nth latest commit" pattern). Try in order until exactly one commit -matches; if several match, pick the most recent and **tell the user which -one you chose**; if none match, stop and ask — do not guess. +If the description doesn't fit the table (e.g. "the commit where I added +the favicon", "where we ripped out the old auth"), pin down a single +commit with the ladder below, then diff `^..`. Try in order +until exactly one commit matches; if several match, pick the most recent +and **tell the user which one you chose**; if none match, stop and ask — +do not guess. 1. **Author.** "by ", "'s last", "by my coworker": ```bash @@ -95,16 +89,12 @@ previous commit, where I added a favicon" but the favicon is at HEAD~2), trust the description over the count and **flag the off-by-one to the user** so they know what you picked. -**Stay within git — never read file contents to disambiguate.** The four -steps above use git only: `git log` (with `--author`, `--grep`, `-S`, -`-G`, `--follow`, `--diff-filter`) and `git ls-files | grep` on -path names. **Do not** `cat`/`head` files, **do not** `grep -r` or `rg` -into working-tree contents, **do not** Read the contents of candidate -files. If the four-step ladder doesn't pin down a unique commit, **stop -and ask the user via `AskUserQuestion`** — surface the candidates the -ladder turned up and let the user pick, or ask for a more specific -description. Reading file contents during search is a token-cost cliff -that requires explicit user consent. +**Stay within git — never read file contents to disambiguate.** Use only +`git log` (with `--author`, `--grep`, `-S`, `-G`, `--follow`, +`--diff-filter`) and `git ls-files | grep` on path names. Don't `cat`, +`grep -r`, `rg`, or `Read` working-tree contents. If the ladder doesn't +pin down a unique commit, AskUserQuestion with the candidates — reading +files during search is a token-cost cliff that requires user consent. **Validate every ref first.** Run `git rev-parse --verify ^{commit}` for each ref the user named directly. If any fails, stop and tell the user @@ -122,16 +112,10 @@ two parts: - `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) +A session hint shows up as language about the *session/conversation/chat* +the diff comes from — e.g. "attached to the session …", "in our session +about X", "from the chat where Y", "session id ``", or a bare +UUID-shaped token (8+ hex chars). **Examples:** @@ -201,19 +185,13 @@ falls back to `pid-` so we still avoid collisions.) git -C "$project_cwd" diff --no-color > "$diff_file" ``` -`` is whatever you resolved in Step 1 (e.g. `HEAD~1 HEAD` or -`feature/test...HEAD` or `--cached`). - For the description path, if the resulting file is empty, **stop** — tell the -user the requested diff is empty and don't launch. (`/askdiff HEAD vs HEAD` -is the canonical empty case.) The working-tree path *can* legitimately be -empty (clean tree); launch anyway and the UI will show "No changes." +user the requested diff is empty and don't launch. The working-tree path +*can* legitimately be 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 5 forwards this to the server as -`ASKDIFF_DIFF_VOLATILE`, which gates the per-file mtime staleness check. +Set `volatile=1` for the working-tree path, `volatile=0` otherwise — Step 5 +forwards this as `ASKDIFF_DIFF_VOLATILE` (gates per-file mtime staleness). ## Step 3 — pick a short label @@ -223,8 +201,7 @@ Use the "Suggested label" column above. For the working-tree case, use ## 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. +(from Step 1). Default is the invoking session. ```bash attached_session="$session_id" # default = invoking @@ -254,14 +231,20 @@ if echo "$explicit_id" | grep -qE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f] : fi else - # Short prefix: glob and disambiguate. - shopt -s nullglob - matches=( "$sessions_dir/${explicit_id}"*.jsonl ) - shopt -u nullglob + # Short prefix: list and disambiguate via `find` (zsh-compatible — see + # footgun in CLAUDE.md: `shopt` is bash-only and silently fails in zsh, + # and zsh arrays are 1-indexed so `${matches[0]}` returns empty). + matches=() + while IFS= read -r f; do + [ -n "$f" ] && matches+=("$f") + done < <(find "$sessions_dir" -maxdepth 1 -name "${explicit_id}*.jsonl" -type f 2>/dev/null) case ${#matches[@]} in 1) - attached_session=$(basename "${matches[0]}" .jsonl) - session_source="explicit" + # Iterate to dodge bash-vs-zsh first-index difference. + for f in "${matches[@]}"; do + attached_session=$(basename "$f" .jsonl) + session_source="explicit" + done ;; 0) # → AskUserQuestion: no session matches "", use current? @@ -273,92 +256,54 @@ else 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. +AskUserQuestion branches: 0 matches → "Use current session" or "Cancel" +(don't launch). Multiple matches → one option per UUID as +` · ` plus "Use current session"; on user pick set +`attached_session` and `session_source="explicit"`. -### 4c. Keywords → grep, decide, possibly ask +### 4c. Keywords → resolve-session, decide, possibly ask -If `session_hint` is `keywords `: +If `session_hint` is `keywords `, call the CLI's +`resolve-session` subcommand. It searches recent project JSONLs (mtime +−30d, excluding the invoking session, top 5 by hit count) and prints +single-line JSON: `{"candidates":[{"uuid":"…","count":N,"age":"…"}, …]}`. ```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 +# Pinned by the build script for the npm tarball; in-repo stays "latest". +ASKDIFF_VERSION="latest" -# 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 + npx -y askdiff@"$ASKDIFF_VERSION" resolve-session \ + --cwd "$project_cwd" \ + --invoking "$session_id" \ + --diff-file "$diff_file" \ + --keyword "" \ + --keyword "" \ + --sha "" \ + --sha "" \ + --branch "" \ + --branch "" ) -rm -f "$needles_file" +echo "$results" ``` -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" | +Repeat `--keyword`/`--sha`/`--branch` per value; omit a flag entirely +if its list is empty. Always pass `--diff-file` (changed file paths feed +the search as additional needles regardless of diff source); omit `--sha` +and `--branch` for working-tree diffs (no commit/branch context). -For ages (used in AskUserQuestion labels): +Read `$results` and route on `.candidates`: -```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 -``` +| Result | Action | +|---|---| +| empty | AskUserQuestion: "no session matched ``. Use current?" → "Use current" or "Cancel and refine" | +| 1 candidate | use that UUID; `attached_session=$uuid`, `session_source="matched"` | +| 2+, top count ≥ 2× second | use top-1; `session_source="matched"` | +| 2–5, comparable counts | AskUserQuestion: one option per candidate as ` · · hits`, plus "Use current session" | -**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. +**Don't widen scope automatically** (e.g. by raising `--max-age-days` or +`--top`). Surface 0/unclear results via AskUserQuestion; re-run only on +user request. ## Step 5 — launch @@ -368,24 +313,19 @@ Run as a single Bash command. Substitute `EXTRA_DIFF_FILE` and ``` set +e -# Pinned version (substituted by the build script when bundling into -# the npm tarball; the in-repo source uses 'latest' so /askdiff in -# this repo always pulls the newest published version). +# Pinned by the build script for the npm tarball; in-repo stays "latest". ASKDIFF_VERSION="latest" -# 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). +# Filled in by Steps 2/3 — keep Step 2's preamble (session_id, project_cwd, +# suffix) and Step 4's resolution (attached_session, session_source) above. EXTRA_DIFF_FILE="" EXTRA_DIFF_LABEL="" log_file="/tmp/askdiff.$suffix.log" pid_file="/tmp/askdiff.$suffix.pid" -# 1. If a server for this session is already running, kill it and remember -# its port so the new server reuses it. Reusing the port keeps the -# open browser tab's URL valid across the restart — the WS will -# auto-reconnect (see lib/ws.ts) and load the freshly-written diff. +# 1. Kill any previous server for this session and reuse its port — keeps +# the open browser tab valid (the WS auto-reconnects). saved_port="" if [ -f "$pid_file" ]; then read -r old_pid saved_port < "$pid_file" 2>/dev/null @@ -401,10 +341,7 @@ if [ -f "$pid_file" ]; then rm -f "$pid_file" fi -# 2. Launch. Pass --port if we have one to reuse; otherwise the CLI picks 7837+. -# The update check is intentionally NOT done here — it would block the -# launch on an npm registry round-trip. We do it after the server is -# up (step 4 below) and just print a passive notice. +# 2. Launch. Reuse port if we have one; otherwise the CLI picks 7837+. port_arg="" [ -n "$saved_port" ] && port_arg="--port $saved_port" @@ -418,44 +355,33 @@ cd "$project_cwd" \ new_pid=$! disown -# Wait for the listening line to land in the log. (`command grep` -# bypasses the harness's grep wrapper — see Step 4c for context.) +# Wait for the listening line. (`command grep` — see Step 4c footguns.) for _ in $(seq 1 60); do command grep -q "listening on" "$log_file" 2>/dev/null && break sleep 0.25 done -# 3. Persist so the next /askdiff invocation in this session -# can find and replace this server (the file path is session-keyed in -# Step 2's preamble). +# 3. Persist for the next /askdiff invocation to find and replace. port=$(sed -nE 's|.*listening on http://localhost:([0-9]+).*|\1|p' "$log_file" | head -1) [ -z "$port" ] && port=7837 echo "$new_pid $port" > "$pid_file" url="http://localhost:$port/" -# Only auto-open the browser on the *first* launch (no saved_port). On -# refresh-style re-invocations, the user's tab is still open and will -# reconnect automatically; opening another tab would be annoying. +# Auto-open only on first launch — refresh re-invocations have a tab open. if [ -z "$saved_port" ]; then (open "$url" >/dev/null 2>&1 || xdg-open "$url" >/dev/null 2>&1) & fi head -10 "$log_file" echo "" -if [ -n "$saved_port" ]; then - echo "Refreshed: same port, new diff. Browser tab will auto-reconnect." -fi +[ -n "$saved_port" ] && echo "Refreshed: same port, new diff. Browser tab will auto-reconnect." echo "UI: $url" echo "Log: $log_file" echo "PID: $new_pid (saved to $pid_file)" -# 4. Update check. Runs *after* the server is up and the URL has been -# printed, so it never blocks launch. Skipped if explicitly disabled, -# or if pinned to 'latest' (in-repo dev case). Network failures are -# silently ignored — never block on a flaky DNS. If a newer version -# exists we print a passive notice with the upgrade command; we do -# NOT prompt or interrupt — the user already has their UI. +# 4. Update check (after launch, never blocking; skipped at "latest" or +# when ASKDIFF_SKIP_UPDATE_CHECK is set; network failures silently ignored). if [ -z "$ASKDIFF_SKIP_UPDATE_CHECK" ] && [ "$ASKDIFF_VERSION" != "latest" ]; then latest=$(curl -fsSL --max-time 2 https://registry.npmjs.org/askdiff/latest 2>/dev/null \ | sed -n 's/.*"version":"\([^"]*\)".*/\1/p' | head -1) @@ -470,24 +396,11 @@ if [ -z "$ASKDIFF_SKIP_UPDATE_CHECK" ] && [ "$ASKDIFF_VERSION" != "latest" ]; th 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) — 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 - refresh-style re-invocation (the `Refreshed:` line is present), the - user's existing tab will reconnect automatically -- **if the output ends with the "A new version of askdiff is available" - block**, surface that to the user verbatim — the upgrade command is - already on a copyable line, no `AskUserQuestion` needed. They can run - it whenever they want; their current `/askdiff` is already working. - -If the `claude session:` line says `(none ...)`, the parent CC manifest -was not found at `$session_file`. That usually means askdiff was -launched from outside a Claude Code session. +The user already sees `UI:` / `Log:` / `PID:` / `Refreshed:` / the +new-version block in the bash output. After launch, only narrate: + +- if `$session_source` is `explicit` or `matched`, say so (e.g. + "attached to matched session 322bc90a (was: invoking)") so the user + knows asks aren't landing in the current session's transcript +- the new-version block verbatim if present — the upgrade command is + copyable as-is, no `AskUserQuestion` needed diff --git a/packages/cli/build.ts b/packages/cli/build.ts index 95fabfc..092fb51 100644 --- a/packages/cli/build.ts +++ b/packages/cli/build.ts @@ -45,12 +45,11 @@ if (!existsSync(skillSrc)) { console.error(`error: SKILL.md not found at ${skillSrc}`); process.exit(1); } -// Substitute the source's `ASKDIFF_VERSION="latest"` with this build's -// exact version so the skill installed on a friend's machine pins to a -// known release. The in-repo source stays as 'latest' so /askdiff in -// this repo always pulls the newest published version. +// Substitute every `ASKDIFF_VERSION="latest"` (Step 4c + Step 5) with this +// build's exact version so the published skill pins to a known release. The +// in-repo source stays "latest" so /askdiff in this repo always pulls newest. const skillBody = readFileSync(skillSrc, "utf8").replace( - /ASKDIFF_VERSION="latest"/, + /ASKDIFF_VERSION="latest"/g, `ASKDIFF_VERSION="${pkg.version}"`, ); writeFileSync(join(distDir, "skill.md"), skillBody); diff --git a/packages/cli/src/cli-integration.test.ts b/packages/cli/src/cli-integration.test.ts new file mode 100644 index 0000000..3ee14a2 --- /dev/null +++ b/packages/cli/src/cli-integration.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; +import { execFile } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +// Path to the built CLI bundle. Tests run after `pnpm --filter askdiff run build`, +// or directly during dev — when the bundle isn't present, tests are skipped to +// avoid masking the real failure. +const REPO_ROOT = resolvePath(__dirname, "..", "..", ".."); +const CLI_PATH = join(REPO_ROOT, "packages", "cli", "dist", "index.js"); + +const FAKE_PROJECT_CWD = "/fake/integration/project"; +const ENCODED = "-fake-integration-project"; +const HOUR_MS = 3600 * 1000; + +const writeJsonl = ( + dir: string, + uuid: string, + lines: readonly string[], + mtimeMs: number, +): void => { + const path = join(dir, `${uuid}.jsonl`); + writeFileSync(path, lines.join("\n")); + const mtime = mtimeMs / 1000; + utimesSync(path, mtime, mtime); +}; + +describe("CLI integration: resolve-session subcommand", () => { + let configDir: string; + let projectDir: string; + let cliExists = true; + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), "askdiff-cli-integration-")); + projectDir = join(configDir, "projects", ENCODED); + mkdirSync(projectDir, { recursive: true }); + + // Skip cleanly if the build hasn't run. + cliExists = existsSync(CLI_PATH); + }); + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }); + }); + + const runCli = async (args: readonly string[]): Promise<{ stdout: string; stderr: string }> => + execFileAsync("node", [CLI_PATH, ...args], { + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir, + }, + }); + + it("respects --cwd when explicitly passed (regression: commander v14 parent/sub flag overlap)", async () => { + if (!cliExists) { + console.warn(`skipping — CLI bundle not built at ${CLI_PATH}`); + return; + } + const NOW = Date.now(); + writeJsonl(projectDir, "11111111-1111-4111-8111-111111111111", ["foo bar"], NOW - HOUR_MS); + + // Empty --cwd dir → no project dir under our isolated CLAUDE_CONFIG_DIR → empty. + const empty = await runCli([ + "resolve-session", + "--cwd", + "/different/path/with/no/sessions", + "--keyword", + "foo", + ]); + expect(JSON.parse(empty.stdout)).toEqual({ candidates: [] }); + + // Real --cwd → finds the fixture session. + const found = await runCli([ + "resolve-session", + "--cwd", + FAKE_PROJECT_CWD, + "--keyword", + "foo", + ]); + const parsed = JSON.parse(found.stdout) as { candidates: { uuid: string; count: number }[] }; + expect(parsed.candidates).toHaveLength(1); + expect(parsed.candidates[0]?.uuid).toBe("11111111-1111-4111-8111-111111111111"); + expect(parsed.candidates[0]?.count).toBe(1); + }); + + it("accepts repeated --keyword flags", async () => { + if (!cliExists) return; + const NOW = Date.now(); + writeJsonl( + projectDir, + "22222222-2222-4222-8222-222222222222", + ["mentions alpha", "mentions beta", "mentions both alpha and beta", "neither"], + NOW - HOUR_MS, + ); + + const r = await runCli([ + "resolve-session", + "--cwd", + FAKE_PROJECT_CWD, + "--keyword", + "alpha", + "--keyword", + "beta", + ]); + const parsed = JSON.parse(r.stdout) as { candidates: { count: number }[] }; + expect(parsed.candidates[0]?.count).toBe(3); // 3 lines contain at least one needle + }); + + it("filters by --invoking", async () => { + if (!cliExists) return; + const NOW = Date.now(); + writeJsonl(projectDir, "33333333-3333-4333-8333-333333333333", ["foo"], NOW - HOUR_MS); + writeJsonl(projectDir, "44444444-4444-4444-8444-444444444444", ["foo foo"], NOW - HOUR_MS); + + const r = await runCli([ + "resolve-session", + "--cwd", + FAKE_PROJECT_CWD, + "--invoking", + "44444444-4444-4444-8444-444444444444", + "--keyword", + "foo", + ]); + const parsed = JSON.parse(r.stdout) as { candidates: { uuid: string }[] }; + expect(parsed.candidates).toHaveLength(1); + expect(parsed.candidates[0]?.uuid).toBe("33333333-3333-4333-8333-333333333333"); + }); + + it("emits empty candidates when no needles passed", async () => { + if (!cliExists) return; + const r = await runCli(["resolve-session", "--cwd", FAKE_PROJECT_CWD]); + expect(JSON.parse(r.stdout)).toEqual({ candidates: [] }); + }); +}); + +// The askdiff-dev skill calls the CLI via `tsx src/index.ts resolve-session …`. +// Static imports of `@askdiff/protocol` (CJS) at the top of `src/index.ts` +// would break this path under ESM. This test guards against re-introducing +// such imports. +describe("CLI integration: dev path (tsx)", () => { + let configDir: string; + let projectDir: string; + + const TSX_PATH = join(REPO_ROOT, "node_modules", ".bin", "tsx"); + const CLI_SOURCE = join(REPO_ROOT, "packages", "cli", "src", "index.ts"); + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), "askdiff-cli-tsx-")); + projectDir = join(configDir, "projects", ENCODED); + mkdirSync(projectDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }); + }); + + it("invokes resolve-session via tsx and returns valid JSON (dev-skill path)", async () => { + if (!existsSync(TSX_PATH)) { + console.warn(`skipping — tsx not found at ${TSX_PATH}`); + return; + } + + const NOW = Date.now(); + writeJsonl( + projectDir, + "55555555-5555-4555-8555-555555555555", + ["dev-path-keyword line"], + NOW - HOUR_MS, + ); + + const { stdout } = await execFileAsync( + TSX_PATH, + [CLI_SOURCE, "resolve-session", "--cwd", FAKE_PROJECT_CWD, "--keyword", "dev-path-keyword"], + { env: { ...process.env, CLAUDE_CONFIG_DIR: configDir } }, + ); + const parsed = JSON.parse(stdout) as { candidates: { uuid: string; count: number }[] }; + expect(parsed.candidates).toHaveLength(1); + expect(parsed.candidates[0]?.uuid).toBe("55555555-5555-4555-8555-555555555555"); + }, 15_000); +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6eec0ab..96f4faf 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,9 +6,16 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Command } from "commander"; import open from "open"; -import { startServer, WS_PATH } from "@askdiff/server"; -import { PROTOCOL_VERSION } from "@askdiff/protocol"; -import { createUiHttpServer } from "./server-bundle.js"; +import { resolveSession } from "./resolve-session.js"; + +// `@askdiff/server`, `@askdiff/protocol`, and `./server-bundle.js` are +// loaded lazily inside `runServer` (the only place that needs them). +// Static imports would force them onto every code path, including +// `resolve-session`, and the protocol package is CJS — statically +// importing it from this ESM module fails under `tsx` (used by the dev +// skill's resolve-session call). Dynamic imports of static-string +// specifiers are bundled by esbuild for the published CLI, so this +// costs nothing at production runtime. const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -66,10 +73,63 @@ async function main(): Promise { await installSkill(opts.force, opts.global); }); + const collect = (v: string, prev: string[]) => [...prev, v]; + const parseIntOpt = (v: string) => Number.parseInt(v, 10); + + program + .command("resolve-session") + .description( + "Search Claude Code session JSONLs in the current project for keyword/path/SHA/branch needles. Prints `{candidates:[{uuid,count,age}]}` JSON to stdout.", + ) + // Note: `-c, --cwd` is intentionally NOT declared here. Commander v14 + // routes it to the parent program (which already declares `-c, --cwd`) + // regardless of where it appears on the command line, so we read it + // from `program.opts()` and fall back to process.cwd(). + .option("--invoking ", "session UUID to exclude (the invoking one)", "") + .option("--diff-file ", "extract `+++ b/` lines from this diff as additional needles") + .option("--keyword ", "user keyword (repeatable)", collect, [] as string[]) + .option("--sha ", "commit SHA (repeatable)", collect, [] as string[]) + .option("--branch ", "branch name (repeatable)", collect, [] as string[]) + .option("--max-age-days ", "mtime cutoff in days", parseIntOpt, 30) + .option("--top ", "max candidates", parseIntOpt, 5) + .action(async (opts: ResolveSessionCliOptions) => { + const parentCwd = program.opts<{ cwd?: string }>().cwd; + const result = await resolveSession({ + cwd: parentCwd ?? process.cwd(), + invoking: opts.invoking, + ...(opts.diffFile !== undefined ? { diffFile: opts.diffFile } : {}), + keywords: opts.keyword, + shas: opts.sha, + branches: opts.branch, + maxAgeDays: opts.maxAgeDays, + top: opts.top, + }); + console.log(JSON.stringify(result)); + }); + await program.parseAsync(process.argv); } +interface ResolveSessionCliOptions { + invoking: string; + diffFile?: string; + keyword: string[]; + sha: string[]; + branch: string[]; + maxAgeDays: number; + top: number; +} + async function runServer(opts: RunOptions): Promise { + // Lazy-load: see top-of-file note. Keeps `resolve-session` off these + // imports so the dev-skill `tsx` invocation works. + const [{ startServer, WS_PATH }, { PROTOCOL_VERSION }, { createUiHttpServer }] = + await Promise.all([ + import("@askdiff/server"), + import("@askdiff/protocol"), + import("./server-bundle.js"), + ]); + const resolved = await resolveOptions(opts); const port = await pickFreePort(resolved.port, resolved.host); diff --git a/packages/cli/src/resolve-session.test.ts b/packages/cli/src/resolve-session.test.ts new file mode 100644 index 0000000..71f32f5 --- /dev/null +++ b/packages/cli/src/resolve-session.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; +import { + mkdirSync, + mkdtempSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { formatAge, resolveSession } from "./resolve-session"; + +const HOUR_S = 3600; +const DAY_S = 86400; +const HOUR_MS = HOUR_S * 1000; +const DAY_MS = DAY_S * 1000; + +const UUIDS = { + invoking: "11111111-1111-4111-8111-111111111111", + hot: "22222222-2222-4222-8222-222222222222", + warm: "33333333-3333-4333-8333-333333333333", + cold: "44444444-4444-4444-8444-444444444444", + stale: "55555555-5555-4555-8555-555555555555", +}; + +const PROJECT_CWD = "/fake/project/path"; + +const writeJsonl = ( + dir: string, + uuid: string, + lines: readonly string[], + mtimeMs: number, +): string => { + const path = join(dir, `${uuid}.jsonl`); + writeFileSync(path, lines.join("\n")); + const mtime = mtimeMs / 1000; + utimesSync(path, mtime, mtime); + return path; +}; + +describe("formatAge", () => { + const now = Date.UTC(2026, 4, 9, 12, 0, 0); + + it("formats sub-day ages in hours", () => { + expect(formatAge(now - 1 * HOUR_MS, now)).toBe("1h ago"); + expect(formatAge(now - 5 * HOUR_MS, now)).toBe("5h ago"); + expect(formatAge(now - (DAY_MS - 1), now)).toBe("23h ago"); + }); + + it("formats day+ ages in days", () => { + expect(formatAge(now - 1 * DAY_MS, now)).toBe("1d ago"); + expect(formatAge(now - 7 * DAY_MS, now)).toBe("7d ago"); + }); + + it("clamps negative (clock-skew) ages to 0h", () => { + expect(formatAge(now + 5 * HOUR_MS, now)).toBe("0h ago"); + }); +}); + +describe("resolveSession", () => { + const NOW = Date.UTC(2026, 4, 9, 12, 0, 0); + let configDir: string; + let projectDir: string; + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), "askdiff-resolve-")); + projectDir = join(configDir, "projects", "-fake-project-path"); + mkdirSync(projectDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }); + }); + + it("returns no candidates when there are no needles", async () => { + writeJsonl(projectDir, UUIDS.hot, ["any text"], NOW - HOUR_MS); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + now: NOW, + }); + expect(r.candidates).toEqual([]); + }); + + it("returns no candidates when the sessions dir doesn't exist", async () => { + const r = await resolveSession({ + cwd: "/nonexistent/path", + configDir, + keywords: ["foo"], + now: NOW, + }); + expect(r.candidates).toEqual([]); + }); + + it("counts matching lines (one match per line, regardless of needle count)", async () => { + writeJsonl( + projectDir, + UUIDS.hot, + [ + "line about foo", + "line about bar", + "line about both foo and bar", + "no match here", + ], + NOW - HOUR_MS, + ); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + keywords: ["foo", "bar"], + now: NOW, + }); + expect(r.candidates).toEqual([ + { uuid: UUIDS.hot, count: 3, age: "1h ago" }, + ]); + }); + + it("excludes the invoking session", async () => { + writeJsonl(projectDir, UUIDS.invoking, ["foo foo foo"], NOW - HOUR_MS); + writeJsonl(projectDir, UUIDS.hot, ["foo"], NOW - HOUR_MS); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + invoking: UUIDS.invoking, + keywords: ["foo"], + now: NOW, + }); + expect(r.candidates.map((c) => c.uuid)).toEqual([UUIDS.hot]); + }); + + it("filters out files older than maxAgeDays", async () => { + writeJsonl(projectDir, UUIDS.hot, ["foo"], NOW - 5 * DAY_MS); + writeJsonl(projectDir, UUIDS.stale, ["foo"], NOW - 60 * DAY_MS); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + keywords: ["foo"], + maxAgeDays: 30, + now: NOW, + }); + expect(r.candidates.map((c) => c.uuid)).toEqual([UUIDS.hot]); + }); + + it("sorts results by count descending and truncates to top N", async () => { + writeJsonl(projectDir, UUIDS.hot, ["foo", "foo", "foo"], NOW - HOUR_MS); + writeJsonl(projectDir, UUIDS.warm, ["foo", "foo"], NOW - HOUR_MS); + writeJsonl(projectDir, UUIDS.cold, ["foo"], NOW - HOUR_MS); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + keywords: ["foo"], + top: 2, + now: NOW, + }); + expect(r.candidates).toEqual([ + { uuid: UUIDS.hot, count: 3, age: "1h ago" }, + { uuid: UUIDS.warm, count: 2, age: "1h ago" }, + ]); + }); + + it("extracts diff '+++ b/' lines as additional needles", async () => { + const diffFile = join(configDir, "diff.txt"); + writeFileSync( + diffFile, + [ + "diff --git a/src/a.ts b/src/a.ts", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "diff --git a/src/b.ts b/src/b.ts", + "+++ b/src/b.ts", + ].join("\n"), + ); + writeJsonl( + projectDir, + UUIDS.hot, + [ + "I touched src/a.ts here", + "and also src/b.ts", + "totally unrelated", + ], + NOW - HOUR_MS, + ); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + diffFile, + now: NOW, + }); + expect(r.candidates).toEqual([ + { uuid: UUIDS.hot, count: 2, age: "1h ago" }, + ]); + }); + + it("ignores a missing diff file but still uses other needles", async () => { + writeJsonl(projectDir, UUIDS.hot, ["foo line"], NOW - HOUR_MS); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + diffFile: "/no/such/file.diff", + keywords: ["foo"], + now: NOW, + }); + expect(r.candidates).toEqual([ + { uuid: UUIDS.hot, count: 1, age: "1h ago" }, + ]); + }); + + it("ignores empty and whitespace-only needles", async () => { + writeJsonl(projectDir, UUIDS.hot, ["foo line"], NOW - HOUR_MS); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + keywords: ["", " ", "foo"], + shas: [""], + branches: [" "], + now: NOW, + }); + expect(r.candidates).toEqual([ + { uuid: UUIDS.hot, count: 1, age: "1h ago" }, + ]); + }); + + it("dedupes overlapping needles across keywords/shas/branches", async () => { + // 'foo' appears in keywords AND shas — should be a single needle. + writeJsonl(projectDir, UUIDS.hot, ["a foo b", "c foo d"], NOW - HOUR_MS); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + keywords: ["foo"], + shas: ["foo"], + now: NOW, + }); + expect(r.candidates[0]?.count).toBe(2); + }); + + it("ignores subdirectories and non-jsonl files in the sessions dir", async () => { + mkdirSync(join(projectDir, "subdir")); + writeFileSync(join(projectDir, "not-jsonl.txt"), "foo foo foo"); + writeJsonl(projectDir, UUIDS.hot, ["foo"], NOW - HOUR_MS); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + keywords: ["foo"], + now: NOW, + }); + expect(r.candidates).toEqual([ + { uuid: UUIDS.hot, count: 1, age: "1h ago" }, + ]); + }); + + it("returns empty candidates when no JSONL has any matches", async () => { + writeJsonl(projectDir, UUIDS.hot, ["nothing here"], NOW - HOUR_MS); + const r = await resolveSession({ + cwd: PROJECT_CWD, + configDir, + keywords: ["foo"], + now: NOW, + }); + expect(r.candidates).toEqual([]); + }); +}); diff --git a/packages/cli/src/resolve-session.ts b/packages/cli/src/resolve-session.ts new file mode 100644 index 0000000..6fcdcb6 --- /dev/null +++ b/packages/cli/src/resolve-session.ts @@ -0,0 +1,175 @@ +import { createReadStream } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; + +const DEFAULT_MAX_AGE_DAYS = 30; +const DEFAULT_TOP = 5; +const SECONDS_PER_DAY = 86400; +const SECONDS_PER_HOUR = 3600; +const DIFF_PATH_PREFIX = "+++ b/"; + +export interface ResolveSessionOptions { + // Project cwd. Used (with configDir) to derive the sessions dir. + cwd: string; + // Override for ${CLAUDE_CONFIG_DIR:-~/.claude}. + configDir?: string; + // Session UUID to exclude from results (the invoking session always + // matches its own JSONL because the user just typed the keywords into it). + invoking?: string; + // User-supplied keyword phrases. + keywords?: readonly string[]; + // Commit SHAs (description-based diffs only). + shas?: readonly string[]; + // Branch names (X...Y / X..Y diff form). + branches?: readonly string[]; + // Optional path to a unified-diff file. `+++ b/` lines are extracted + // as additional needles — catches sessions that Read/Edit/Write'd those files. + diffFile?: string; + // mtime cutoff in days. Default 30. + maxAgeDays?: number; + // Cap on returned candidates. Default 5. + top?: number; + // Test seam: override Date.now(). + now?: number; +} + +export interface SessionCandidate { + uuid: string; + count: number; + age: string; +} + +export interface ResolveSessionResult { + candidates: SessionCandidate[]; +} + +export const resolveSession = async ( + opts: ResolveSessionOptions, +): Promise => { + const configDir = + opts.configDir ?? process.env["CLAUDE_CONFIG_DIR"] ?? join(homedir(), ".claude"); + const sessionsDir = join( + configDir, + "projects", + encodeProjectPath(resolve(opts.cwd)), + ); + const maxAgeDays = opts.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS; + const top = opts.top ?? DEFAULT_TOP; + const nowMs = opts.now ?? Date.now(); + const cutoffMs = nowMs - maxAgeDays * SECONDS_PER_DAY * 1000; + + const needles = await buildNeedles({ + keywords: opts.keywords ?? [], + shas: opts.shas ?? [], + branches: opts.branches ?? [], + ...(opts.diffFile !== undefined ? { diffFile: opts.diffFile } : {}), + }); + if (needles.length === 0) return { candidates: [] }; + + const files = await listRecentJsonls(sessionsDir, cutoffMs, opts.invoking ?? ""); + const counted: { uuid: string; count: number; mtimeMs: number }[] = []; + for (const f of files) { + const count = await countMatchingLines(f.path, needles); + if (count > 0) counted.push({ uuid: f.uuid, count, mtimeMs: f.mtimeMs }); + } + + counted.sort((a, b) => b.count - a.count); + return { + candidates: counted.slice(0, top).map((c) => ({ + uuid: c.uuid, + count: c.count, + age: formatAge(c.mtimeMs, nowMs), + })), + }; +}; + +const encodeProjectPath = (absolute: string): string => absolute.replace(/\//g, "-"); + +interface NeedleInputs { + keywords: readonly string[]; + shas: readonly string[]; + branches: readonly string[]; + diffFile?: string; +} + +const buildNeedles = async (inputs: NeedleInputs): Promise => { + const set = new Set(); + const add = (s: string) => { + const trimmed = s.trim(); + if (trimmed.length > 0) set.add(trimmed); + }; + for (const k of inputs.keywords) add(k); + for (const s of inputs.shas) add(s); + for (const b of inputs.branches) add(b); + if (inputs.diffFile !== undefined) { + try { + const text = await readFile(inputs.diffFile, "utf8"); + for (const line of text.split("\n")) { + if (line.startsWith(DIFF_PATH_PREFIX)) { + add(line.slice(DIFF_PATH_PREFIX.length)); + } + } + } catch { + // Diff file missing/unreadable is benign — proceed with the other needles. + } + } + return Array.from(set); +}; + +interface JsonlEntry { + path: string; + uuid: string; + mtimeMs: number; +} + +const listRecentJsonls = async ( + dir: string, + cutoffMs: number, + excludeUuid: string, +): Promise => { + const dirents = await readdir(dir, { withFileTypes: true }).catch(() => []); + const out: JsonlEntry[] = []; + for (const e of dirents) { + if (!e.isFile() || !e.name.endsWith(".jsonl")) continue; + const uuid = e.name.slice(0, -".jsonl".length); + if (uuid === excludeUuid) continue; + const path = join(dir, e.name); + const s = await stat(path).catch(() => null); + if (s === null) continue; + if (s.mtimeMs < cutoffMs) continue; + out.push({ path, uuid, mtimeMs: s.mtimeMs }); + } + return out; +}; + +const countMatchingLines = async ( + filePath: string, + needles: readonly string[], +): Promise => { + let count = 0; + const stream = createReadStream(filePath, { encoding: "utf8" }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + try { + for await (const line of rl) { + for (const n of needles) { + if (line.includes(n)) { + count++; + break; + } + } + } + } catch { + // Partial result on read error — return what we have rather than crash. + } + return count; +}; + +export const formatAge = (mtimeMs: number, nowMs: number): string => { + const ageSec = Math.max(0, Math.floor((nowMs - mtimeMs) / 1000)); + if (ageSec < SECONDS_PER_DAY) { + return `${String(Math.floor(ageSec / SECONDS_PER_HOUR))}h ago`; + } + return `${String(Math.floor(ageSec / SECONDS_PER_DAY))}d ago`; +};