diff --git a/dot_claude/docs/spawning-sessions.md b/dot_claude/docs/spawning-sessions.md index 89949bd..3d8471f 100644 --- a/dot_claude/docs/spawning-sessions.md +++ b/dot_claude/docs/spawning-sessions.md @@ -35,6 +35,16 @@ Launches `claude --dangerously-skip-permissions --model sonnet` in a fresh tab, `cd`'d into ``. Runs in `` directly — no worktree — so use it for read-only or single-stream work. +Pass `--keep-going` for a standing/autonomous-goal prompt (e.g. "work through this +epic and keep going"). It installs a session-scoped Stop hook +(`keep-going-hook.sh`) in the launch dir's `.claude/settings.local.json` that +blocks the session from stopping until it creates `.claude/goal-done`, bounded by +`KEEP_GOING_MAX` continuations (default 40, override via env). A human aborts the +loop early by creating `.claude/goal-stop` in that worktree — no need to kill the +session. Leave `--keep-going` off for a one-shot ask that should stop at its +natural turn boundary; the hook is opt-in per spawn, not global, because a broken +done-when signal would otherwise loop a plain one-off task too. + **Long/complex prompts → pipe via `@-`.** The script reads stdin into a *unique ephemeral* temp file under `$TMPDIR/claude-spawn` (auto-pruned after a day) and points Claude at it. This avoids cmd.exe quoting issues AND the old foot-gun of hand-writing a @@ -108,6 +118,21 @@ Closes the named zellij tab, removes the git worktree at `.wt/`, and deletes the local `wt/` branch. Refuses to delete a branch with unmerged commits unless `--force` is passed. `--no-tab` skips the zellij step (worktree + branch only). +## `zj-fleet-status.sh` — one line per running worker + +```bash +bash ~/.claude/scripts/zj-fleet-status.sh +``` + +`zellaude-hook.sh` already fires on every hook event; it now also upserts each +event into a shared JSON map (keyed by pane id) at +`~/.config/zellij/plugins/zellaude-fleet-state.json` (or the Windows AppData +equivalent). `zj-fleet-status.sh` reads that map and prints pane, ticket slug +(from the worktree dirname), last hook event, seconds idle, and PR state +(`gh pr list --head wt/`, cached for 30s so a run across many panes +doesn't hammer the GitHub API). Run it on demand in a floating pane; it's a +plain script, not a plugin — see DEV-14. + ```bash # Normal teardown after the session merged its branch bash ~/.claude/scripts/zj-worktree-close.sh projektor-access 'C:\Users\tajdi\projektor-workspace' diff --git a/dot_claude/scripts/fix-project-path-hook.sh b/dot_claude/scripts/fix-project-path-hook.sh new file mode 100644 index 0000000..41af1a1 --- /dev/null +++ b/dot_claude/scripts/fix-project-path-hook.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail +trap 'exit 0' ERR + +input=$(cat) +path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty') +cwd=$(printf '%s' "$input" | jq -r '.cwd // empty') + +[[ -z "$path" ]] && exit 0 + +norm=$(printf '%s' "$path" | tr '\\' '/') +if [[ "$norm" != *"/.claude/projects/"* ]]; then + exit 0 +fi + +remainder=$(printf '%s' "$norm" | sed -E 's#^.*/\.claude/projects/[^/]+/##') + +if [[ -z "$remainder" || "$remainder" == "$norm" || -z "$cwd" ]]; then + echo "Path '$path' runs through ~/.claude/projects//, which is the transcript store, not a repo. Use a path relative to your actual working directory instead." >&2 + exit 2 +fi + +suggestion="${cwd%/}/$remainder" +echo "Path '$path' resolves inside ~/.claude/projects//, which is the transcript store, not your repo root. Your working directory is '$cwd' — you likely meant '$suggestion'. Retry with that path." >&2 +exit 2 diff --git a/dot_claude/scripts/fix-project-path-hook.test.sh b/dot_claude/scripts/fix-project-path-hook.test.sh new file mode 100644 index 0000000..48420fc --- /dev/null +++ b/dot_claude/scripts/fix-project-path-hook.test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="$here/fix-project-path-hook.sh" +fail=0 + +check() { + local desc="$1" json="$2" want_rc="$3" want_grep="${4:-}" + local out rc + out=$(printf '%s' "$json" | bash "$hook" 2>&1) && rc=0 || rc=$? + if [[ "$rc" != "$want_rc" ]]; then + echo "FAIL ($desc): exit $rc, expected $want_rc. Output: $out"; fail=1 + fi + if [[ -n "$want_grep" && "$out" != *"$want_grep"* ]]; then + echo "FAIL ($desc): output missing '$want_grep'. Output: $out"; fail=1 + fi +} + +check "misresolved Read path" \ + '{"cwd":"C:/Users/tajdi/bestefforttools","tool_input":{"file_path":"C:/Users/tajdi/.claude/projects/C--Users-tajdi-bestefforttools/lib/seo.ts"}}' \ + 2 "C:/Users/tajdi/bestefforttools/lib/seo.ts" + +check "normal repo path passes" \ + '{"cwd":"C:/Users/tajdi/bestefforttools","tool_input":{"file_path":"C:/Users/tajdi/bestefforttools/lib/seo.ts"}}' \ + 0 + +check "misresolved Glob path (path field)" \ + '{"cwd":"C:/Users/tajdi/bestefforttools","tool_input":{"path":"C:/Users/tajdi/.claude/projects/C--Users-tajdi-bestefforttools"}}' \ + 2 + +check "Bash tool with no path field passes" \ + '{"cwd":"C:/Users/tajdi/bestefforttools","tool_input":{"command":"ls"}}' \ + 0 + +check "Windows backslash path" \ + '{"cwd": "C:\\Users\\tajdi\\bestefforttools", "tool_input": {"file_path": "C:\\Users\\tajdi\\.claude\\projects\\C--Users-tajdi-bestefforttools\\lib\\seo.ts"}}' \ + 2 "transcript store" + +if [[ $fail -eq 0 ]]; then + echo "fix-project-path-hook.test.sh: all cases passed" +else + exit 1 +fi diff --git a/dot_claude/scripts/keep-going-hook.sh b/dot_claude/scripts/keep-going-hook.sh new file mode 100644 index 0000000..d750c48 --- /dev/null +++ b/dot_claude/scripts/keep-going-hook.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" +state_dir="$project_dir/.claude" +sentinel="$state_dir/goal-done" +abort="$state_dir/goal-stop" +counter_file="$state_dir/goal-tries" +max_file="$state_dir/keep-going-max" + +cat >/dev/null || true + +if [[ -f "$sentinel" ]]; then + exit 0 +fi + +if [[ -f "$abort" ]]; then + echo "keep-going-hook: $abort present, allowing stop without goal-done." >&2 + exit 0 +fi + +mkdir -p "$state_dir" + +max=40 +[[ -f "$max_file" ]] && max=$(cat "$max_file" 2>/dev/null || echo 40) + +tries=0 +[[ -f "$counter_file" ]] && tries=$(cat "$counter_file" 2>/dev/null || echo 0) +tries=$((tries + 1)) +printf '%s' "$tries" > "$counter_file" + +if (( tries > max )); then + echo "keep-going-hook: hit max continuations ($max) without $sentinel, allowing stop." >&2 + exit 0 +fi + +reason="Standing goal not yet complete ($tries/$max continuations used). Keep working the goal from your original prompt. When it is FULLY met, create .claude/goal-done (e.g. touch .claude/goal-done) before your final message. If you are stuck, say so plainly instead of stopping silently." +printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$reason" | jq -Rs .)" diff --git a/dot_claude/scripts/keep-going-hook.test.sh b/dot_claude/scripts/keep-going-hook.test.sh new file mode 100644 index 0000000..9411a5c --- /dev/null +++ b/dot_claude/scripts/keep-going-hook.test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="$here/keep-going-hook.sh" +fail=0 + +run() { + local desc="$1"; shift + local tmp out rc + tmp=$(mktemp -d) + mkdir -p "$tmp/.claude" + "$@" "$tmp" + out=$(CLAUDE_PROJECT_DIR="$tmp" bash "$hook" <<<'{}') && rc=0 || rc=$? + if [[ "$rc" != 0 ]]; then + echo "FAIL ($desc): hook exited $rc, expected 0"; fail=1 + fi + rm -rf "$tmp" + printf '%s' "$out" +} + +setup_none() { :; } +setup_done() { touch "$1/.claude/goal-done"; } +setup_stop() { touch "$1/.claude/goal-stop"; } +setup_maxed() { echo 2 > "$1/.claude/keep-going-max"; echo 5 > "$1/.claude/goal-tries"; } + +out=$(run "no sentinel" setup_none) +[[ "$out" == *'"decision":"block"'* ]] || { echo "FAIL: expected block when no sentinel, got: $out"; fail=1; } + +out=$(run "goal-done present" setup_done) +[[ -z "$out" ]] || { echo "FAIL: expected empty stdout with goal-done, got: $out"; fail=1; } + +out=$(run "goal-stop present" setup_stop) +[[ -z "$out" ]] || { echo "FAIL: expected empty stdout with goal-stop, got: $out"; fail=1; } + +out=$(run "max continuations exhausted" setup_maxed) +[[ -z "$out" ]] || { echo "FAIL: expected empty stdout past max, got: $out"; fail=1; } + +if [[ $fail -eq 0 ]]; then + echo "keep-going-hook.test.sh: all cases passed" +else + exit 1 +fi diff --git a/dot_claude/scripts/zellaude-hook.sh b/dot_claude/scripts/zellaude-hook.sh index 8b96a9d..c9a3ae3 100644 --- a/dot_claude/scripts/zellaude-hook.sh +++ b/dot_claude/scripts/zellaude-hook.sh @@ -45,12 +45,10 @@ PAYLOAD=$(jq -nc \ ts_ms: ($ts_ms | tonumber) }') -# Permission request: bell + desktop notification +# Permission request: bell + OSC9 desktop notification if [ "$HOOK_EVENT" = "PermissionRequest" ]; then printf '\a' > /dev/tty 2>/dev/null || true - # Read notification setting (default: Always) - # Check Unix path first, then Windows AppData path SETTINGS_FILE="$HOME/.config/zellij/plugins/zellaude.json" if [ ! -f "$SETTINGS_FILE" ] && [ -n "${APPDATA:-}" ]; then WIN_PATH=$(cygpath -u "$APPDATA" 2>/dev/null || echo "") @@ -62,80 +60,54 @@ if [ "$HOOK_EVENT" = "PermissionRequest" ]; then NOTIFY_MODE=$(jq -r '.notifications // "Always"' "$SETTINGS_FILE" 2>/dev/null) fi - # For "Unfocused" mode, check if the terminal app is frontmost SHOULD_NOTIFY=false case "$NOTIFY_MODE" in Always) SHOULD_NOTIFY=true ;; Unfocused) - TERM_FOCUSED=false - case "$(uname)" in - Darwin) - # Map TERM_PROGRAM to macOS process name - EXPECTED="${TERM_PROGRAM:-}" - case "$EXPECTED" in - Apple_Terminal) EXPECTED="Terminal" ;; - iTerm.app) EXPECTED="iTerm2" ;; - esac - FRONT_APP=$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null) - [ "$FRONT_APP" = "$EXPECTED" ] && TERM_FOCUSED=true - ;; - Linux) - # X11: check if focused window belongs to our terminal - if command -v xdotool >/dev/null 2>&1; then - ACTIVE_PID=$(xdotool getactivewindow getwindowpid 2>/dev/null) - if [ -n "$ACTIVE_PID" ]; then - PID=$$ - while [ "$PID" -gt 1 ] 2>/dev/null; do - [ "$PID" = "$ACTIVE_PID" ] && { TERM_FOCUSED=true; break; } - PID=$(ps -o ppid= -p "$PID" 2>/dev/null | tr -d ' ') - done - fi - fi - ;; - esac - [ "$TERM_FOCUSED" = false ] && SHOULD_NOTIFY=true + SHOULD_NOTIFY=true + WARNED="/tmp/zellaude-unfocused-unsupported-${ZELLIJ_PANE_ID}" + if [ ! -f "$WARNED" ]; then + touch "$WARNED" + echo "zellaude-hook: 'Unfocused' needs zellij's forwarded focus state (0.45+, not yet released); notifying Always instead (DEV-11)." >&2 + fi ;; esac if [ "$SHOULD_NOTIFY" = true ]; then TOOL_SUFFIX="" [ -n "$TOOL_NAME" ] && TOOL_SUFFIX=" — $TOOL_NAME" - TITLE="⚠ Claude Code" + TITLE="Claude Code" MESSAGE="Permission requested${TOOL_SUFFIX}" - # Rate-limit: one notification per pane per 10 seconds LOCK="/tmp/zellaude-notify-${ZELLIJ_PANE_ID}" NOW=$(date +%s) LAST=0 [ -f "$LOCK" ] && LAST=$(cat "$LOCK" 2>/dev/null) if [ $((NOW - LAST)) -ge 10 ]; then echo "$NOW" > "$LOCK" - - # Click callback: activate terminal + focus the pane - ZELLIJ_BIN=$(command -v zellij) - FOCUS_CMD="${ZELLIJ_BIN} -s '${ZELLIJ_SESSION_NAME}' pipe --name zellaude:focus -- ${ZELLIJ_PANE_ID}" - - case "$(uname)" in - Darwin) - [ -n "${TERM_PROGRAM:-}" ] && FOCUS_CMD="open -a '${TERM_PROGRAM}' && ${FOCUS_CMD}" - if command -v terminal-notifier >/dev/null 2>&1; then - terminal-notifier \ - -title "$TITLE" \ - -message "$MESSAGE" \ - -execute "$FOCUS_CMD" & - else - osascript -e "display notification \"$MESSAGE\" with title \"$TITLE\"" & - fi - ;; - Linux) - if command -v notify-send >/dev/null 2>&1; then - notify-send "$TITLE" "$MESSAGE" & - fi - ;; - esac + printf '\033]9;%s: %s\033\\' "$TITLE" "$MESSAGE" > /dev/tty 2>/dev/null || true fi fi fi -# Send to plugin (hook is already async, no need to background) +FLEET_STATE_DIR="$HOME/.config/zellij/plugins" +if [ ! -d "$FLEET_STATE_DIR" ] && [ -n "${APPDATA:-}" ]; then + WIN_PATH=$(cygpath -u "$APPDATA" 2>/dev/null || echo "") + [ -n "$WIN_PATH" ] && FLEET_STATE_DIR="$WIN_PATH/zellij/plugins" +fi +mkdir -p "$FLEET_STATE_DIR" 2>/dev/null || true +FLEET_STATE_FILE="$FLEET_STATE_DIR/zellaude-fleet-state.json" +LOCKDIR="$FLEET_STATE_FILE.lock" +i=0 +until mkdir "$LOCKDIR" 2>/dev/null; do + i=$((i + 1)) + [ "$i" -ge 20 ] && break + sleep 0.05 +done +CURRENT="{}" +[ -f "$FLEET_STATE_FILE" ] && CURRENT=$(cat "$FLEET_STATE_FILE" 2>/dev/null || echo "{}") +printf '%s' "$CURRENT" | jq --argjson entry "$PAYLOAD" '.[$entry.pane_id | tostring] = $entry' > "$FLEET_STATE_FILE.tmp" 2>/dev/null \ + && mv "$FLEET_STATE_FILE.tmp" "$FLEET_STATE_FILE" +rmdir "$LOCKDIR" 2>/dev/null || true + zellij pipe --name "zellaude" -- "$PAYLOAD" diff --git a/dot_claude/scripts/zj-claude.sh b/dot_claude/scripts/zj-claude.sh index ace16f0..0b37caf 100644 --- a/dot_claude/scripts/zj-claude.sh +++ b/dot_claude/scripts/zj-claude.sh @@ -31,7 +31,8 @@ # No-op (exit 0) when not inside a zellij session. set -euo pipefail -here=0; base=""; setup="" +here=0; base=""; setup=""; keep_going=0 +keep_going_max="${KEEP_GOING_MAX:-40}" model="${MODEL:-sonnet}" settle="${ZJ_SETTLE:-1.5}" # seconds for a new tab's chrome to settle before layout loads @@ -41,6 +42,7 @@ while [[ "${1:-}" == --* ]]; do --base) base="${2:?--base needs a ref}"; shift 2;; --setup) setup="${2:?--setup needs a command}"; shift 2;; --model) model="${2:?--model needs a ref}"; shift 2;; + --keep-going) keep_going=1; shift;; --) shift; break;; *) echo "zj-claude: unknown option $1" >&2; exit 2;; esac @@ -61,12 +63,26 @@ if [[ "$prompt_arg" == "@-" ]]; then promptfile="$tf" elif [[ "$prompt_arg" == @* ]]; then promptfile="${prompt_arg:1}" + if [[ $keep_going -eq 1 ]]; then + tf="$(mktemp "$spawndir/${name}-XXXXXX.md")" + cat "$promptfile" > "$tf" + promptfile="$tf" + fi else tf="$(mktemp "$spawndir/${name}-XXXXXX.md")" printf '%s' "$prompt_arg" > "$tf" promptfile="$tf" fi +if [[ $keep_going -eq 1 ]]; then + { + printf '\n## Standing-goal loop\n' + printf 'A Stop hook will block you from ending this session until the goal above is met, up to %s continuations.\n' "$keep_going_max" + printf 'When the goal is FULLY met, create .claude/goal-done (e.g. `touch .claude/goal-done`) before your final message.\n' + printf 'If a human needs to abort the loop early, they create .claude/goal-stop in this worktree.\n' + } >> "$promptfile" +fi + # ── Worktree creation ──────────────────────────────────────────────────────── if [[ $here -eq 1 ]]; then launch_dir="$workdir" @@ -99,6 +115,20 @@ else fi # ── Path conversion (Windows/MSYS) ────────────────────────────────────────── +if [[ $keep_going -eq 1 ]]; then + mkdir -p "$launch_dir/.claude" + printf '%s' "$keep_going_max" > "$launch_dir/.claude/keep-going-max" + settings_file="$launch_dir/.claude/settings.local.json" + hook_entry='{"matcher":"","hooks":[{"type":"command","command":"bash ~/.claude/scripts/keep-going-hook.sh"}]}' + if [[ -f "$settings_file" ]] && command -v jq >/dev/null 2>&1; then + jq --argjson entry "$hook_entry" '.hooks.Stop = ((.hooks.Stop // []) + [$entry])' "$settings_file" > "$settings_file.tmp" && mv "$settings_file.tmp" "$settings_file" + elif [[ -f "$settings_file" ]]; then + echo "zj-claude: --keep-going: $settings_file already exists and jq is unavailable to merge, skipping hook install" >&2 + else + printf '{"hooks":{"Stop":[%s]}}' "$hook_entry" | jq . > "$settings_file" + fi +fi + to_native() { command -v cygpath >/dev/null && cygpath -m "$1" || printf '%s' "$1"; } bash_native=$(to_native "$(command -v bash)") launch_dir_native=$(to_native "$launch_dir") diff --git a/dot_claude/scripts/zj-fleet-status.sh b/dot_claude/scripts/zj-fleet-status.sh new file mode 100644 index 0000000..b2fda22 --- /dev/null +++ b/dot_claude/scripts/zj-fleet-status.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +STATE_DIR="$HOME/.config/zellij/plugins" +if [ ! -d "$STATE_DIR" ] && [ -n "${APPDATA:-}" ]; then + WIN_PATH=$(cygpath -u "$APPDATA" 2>/dev/null || echo "") + [ -n "$WIN_PATH" ] && STATE_DIR="$WIN_PATH/zellij/plugins" +fi +STATE_FILE="$STATE_DIR/zellaude-fleet-state.json" +PR_CACHE="${TMPDIR:-/tmp}/zj-fleet-status-pr-cache.json" +PR_CACHE_TTL="${ZJ_FLEET_PR_CACHE_TTL:-30}" + +if [ ! -f "$STATE_FILE" ]; then + echo "No fleet state yet at $STATE_FILE (no hook events recorded)." + exit 0 +fi + +now_ms=$(jq -nc 'now * 1000 | floor') + +pr_state_for() { + local branch="$1" + local now cache_ts cached + now=$(date +%s) + if [ -f "$PR_CACHE" ]; then + cache_ts=$(jq -r '._fetched_at // 0' "$PR_CACHE" 2>/dev/null || echo 0) + if [ $((now - cache_ts)) -lt "$PR_CACHE_TTL" ]; then + cached=$(jq -r --arg b "$branch" '.[$b] // empty' "$PR_CACHE" 2>/dev/null || echo "") + [ -n "$cached" ] && { printf '%s' "$cached"; return; } + fi + fi + if ! command -v gh >/dev/null 2>&1; then + printf 'n/a' + return + fi + local state + state=$(gh pr list --head "$branch" --json state --jq '.[0].state // "none"' 2>/dev/null || echo "none") + local base="{}" + [ -f "$PR_CACHE" ] && base=$(cat "$PR_CACHE" 2>/dev/null || echo "{}") + printf '%s' "$base" | jq --arg b "$branch" --arg s "$state" --argjson ts "$now" \ + '.[$b] = $s | ._fetched_at = $ts' > "$PR_CACHE.tmp" 2>/dev/null && mv "$PR_CACHE.tmp" "$PR_CACHE" + printf '%s' "$state" +} + +printf '%-14s %-20s %-12s %8s %s\n' "PANE" "TICKET" "EVENT" "IDLE(s)" "PR" + +jq -r 'to_entries[] | [.value.pane_id, .value.cwd // "", .value.hook_event, .value.tool_name // "", .value.ts_ms] | @tsv' "$STATE_FILE" | tr -d '\r' | +while IFS=$'\t' read -r pane_id cwd hook_event tool_name ts_ms; do + slug=$(basename "$cwd" 2>/dev/null || echo "?") + idle=$(( (now_ms - ts_ms) / 1000 )) + event_label="$hook_event" + [ -n "$tool_name" ] && event_label="$hook_event:$tool_name" + branch="wt/$slug" + pr=$(pr_state_for "$branch") + printf '%-14s %-20s %-12s %8s %s\n' "$pane_id" "$slug" "$event_label" "$idle" "$pr" +done diff --git a/dot_claude/scripts/zj-fleet-status.test.sh b/dot_claude/scripts/zj-fleet-status.test.sh new file mode 100644 index 0000000..881ae70 --- /dev/null +++ b/dot_claude/scripts/zj-fleet-status.test.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +pass=0 +fail=0 +check() { + local desc="$1" got="$2" want="$3" + if [ "$got" = "$want" ]; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + echo "FAIL: $desc" + echo " want: $want" + echo " got: $got" + fi +} + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +export HOME="$TMP/home" +mkdir -p "$HOME" + +run_hook() { + ZELLIJ_SESSION_NAME=test-session ZELLIJ_PANE_ID="$1" bash -c "echo '$2' | env -u APPDATA bash zellaude-hook.sh" 2>/dev/null || true +} + +run_hook 1 '{"hook_event_name":"PreToolUse","session_id":"s1","tool_name":"Bash","cwd":"/c/repo.wt/DEV-1"}' >/dev/null +run_hook 2 '{"hook_event_name":"PreToolUse","session_id":"s2","tool_name":"Edit","cwd":"/c/repo.wt/DEV-2"}' >/dev/null + +STATE_FILE="$HOME/.config/zellij/plugins/zellaude-fleet-state.json" +check "state file created" "$([ -f "$STATE_FILE" ] && echo yes || echo no)" "yes" +check "pane 1 tracked" "$(jq -r '."1".cwd' "$STATE_FILE")" "C:/repo.wt/DEV-1" +check "pane 2 tracked" "$(jq -r '."2".hook_event' "$STATE_FILE")" "PreToolUse" + +run_hook 1 '{"hook_event_name":"PostToolUse","session_id":"s1","tool_name":"Bash","cwd":"/c/repo.wt/DEV-1"}' >/dev/null +check "pane 1 overwritten by later event" "$(jq -r '."1".hook_event' "$STATE_FILE")" "PostToolUse" +check "pane 1 cwd unchanged by overwrite" "$(jq -r '."1".cwd' "$STATE_FILE")" "C:/repo.wt/DEV-1" +check "pane count stable after overwrite" "$(jq 'length' "$STATE_FILE")" "2" + +OUT=$(HOME="$HOME" APPDATA= env -u GH_TOKEN bash zj-fleet-status.sh 2>&1) +check "render includes pane 1 ticket slug" "$(printf '%s' "$OUT" | grep -c 'DEV-1')" "1" +check "render includes pane 2 ticket slug" "$(printf '%s' "$OUT" | grep -c 'DEV-2')" "1" + +EMPTY_HOME="$TMP/empty-home" +mkdir -p "$EMPTY_HOME" +OUT2=$(HOME="$EMPTY_HOME" APPDATA= bash zj-fleet-status.sh; echo "exit:$?") +check "no state file yields friendly message and exit 0" "$(printf '%s' "$OUT2" | tail -1)" "exit:0" + +echo "pass=$pass fail=$fail" +[ "$fail" -eq 0 ] diff --git a/dot_claude/settings.json b/dot_claude/settings.json index b4ba48b..8cd4fcf 100644 --- a/dot_claude/settings.json +++ b/dot_claude/settings.json @@ -190,6 +190,16 @@ } ] }, + { + "matcher": "Read|Edit|Write|Glob|Grep", + "hooks": [ + { + "type": "command", + "command": "bash ~/.claude/scripts/fix-project-path-hook.sh", + "timeout": 5 + } + ] + }, { "matcher": "", "hooks": [