diff --git a/brepl b/brepl index cdcc2c3..c4b61da 100755 --- a/brepl +++ b/brepl @@ -18,12 +18,13 @@ io/file .getParentFile .getAbsolutePath)] - (doseq [lib ["hook_utils" "validator" "backup" "installer"]] + (doseq [lib ["hook_utils" "validator" "backup" "installer" "stop_hooks"]] (load-file (str script-dir "/lib/" lib ".clj")))) (require '[brepl.lib.validator :as validator] '[brepl.lib.backup :as backup] - '[brepl.lib.installer :as installer]) + '[brepl.lib.installer :as installer] + '[brepl.lib.stop-hooks :as stop-hooks]) (def cli-spec {:e {:desc "Expression to evaluate (everything after -e is treated as code)" @@ -691,12 +692,64 @@ (println "Hooks uninstalled successfully.") (System/exit 0)) -(defn handle-session-end [args] - (if (empty? args) - (do (println "Error: session-id required") - (System/exit 1)) - (do (backup/cleanup-session (first args)) - (System/exit 0)))) +(defn handle-session-end [_args] + (let [input (try + (json/parse-stream *in* true) + (catch Exception e + (binding [*out* *err*] + (println "Error parsing SessionEnd event JSON:" (.getMessage e))) + (System/exit 1))) + session-id (or (:session_id input) "unknown")] + (backup/cleanup-session session-id) + ;; Also cleanup stop hook state + (stop-hooks/cleanup-state session-id) + (System/exit 0))) + +(defn handle-stop [_args] + ;; Initialize stop-hooks dynamic vars for REPL hook execution + (alter-var-root #'stop-hooks/*nrepl-eval-fn* (constantly eval-expression)) + (alter-var-root #'stop-hooks/*resolve-port-fn* (constantly resolve-port)) + (alter-var-root #'stop-hooks/*resolve-host-fn* (constantly resolve-host)) + + ;; Parse stdin JSON to get session_id + (let [input (try + (json/parse-stream *in* true) + (catch Exception e + (binding [*out* *err*] + (println "Error parsing Stop event JSON:" (.getMessage e))) + (System/exit 1))) + session-id (or (:session_id input) "unknown")] + + ;; Load and validate hooks config + (let [config (stop-hooks/load-hooks)] + (cond + ;; No config file - success, no hooks to run + (nil? config) + (System/exit 0) + + ;; Parse error in config + (:error config) + (do + (binding [*out* *err*] + (println "Error parsing .brepl/hooks.edn:" (:error config))) + (System/exit 1)) + + ;; Validate config + :else + (let [validation (stop-hooks/validate-hooks config)] + (if-not (:valid? validation) + (do + (binding [*out* *err*] + (println "Invalid .brepl/hooks.edn configuration:") + (println (pr-str (:errors validation)))) + (System/exit 1)) + + ;; Run hooks + (let [result (stop-hooks/run-stop-hooks session-id config)] + (when (pos? (:exit-code result)) + (binding [*out* *err*] + (println (:message result)))) + (System/exit (:exit-code result))))))))) (defn handle-skill-install [_args] (let [result (installer/install-skill)] @@ -719,22 +772,24 @@ (println "USAGE:") (println " brepl hook validate [--debug] ") (println " brepl hook eval [--debug] ") + (println " brepl hook stop") (println " brepl hook install [--strict-eval] [--debug]") (println " brepl hook uninstall") - (println " brepl hook session-end ") + (println " brepl hook session-end") (println) (println "SUBCOMMANDS:") (println " validate Validate Clojure file syntax before edit") (println " eval Evaluate file and check for runtime errors") + (println " stop Run stop hooks from .brepl/hooks.edn") (println " install Install hooks in .claude/settings.local.json") (println " uninstall Remove hooks from .claude/settings.local.json") - (println " session-end Clean up session backup files") + (println " session-end Clean up session backup files (reads JSON from stdin)") (println) (println "FLAGS:") (println " --debug Save hook JSON input to ./tmp/hooks-requests/") (println " --strict-eval Exit with error on eval failures (install only)") (System/exit (if (and subcommand - (not (contains? #{"validate" "eval" "install" "uninstall" "session-end"} subcommand))) + (not (contains? #{"validate" "eval" "stop" "install" "uninstall" "session-end"} subcommand))) 1 0))) (defn show-balance-help [] @@ -809,6 +864,7 @@ (case subcommand "validate" (handle-validate args) "eval" (handle-eval args) + "stop" (handle-stop args) "install" (handle-install args) "uninstall" (handle-uninstall args) "session-end" (handle-session-end args) diff --git a/lib/installer.clj b/lib/installer.clj index 41cfd20..63b355e 100644 --- a/lib/installer.clj +++ b/lib/installer.clj @@ -1,7 +1,8 @@ (ns brepl.lib.installer "Hook installer for Claude Code integration." (:require [babashka.fs :as fs] - [cheshire.core :as json])) + [cheshire.core :as json] + [clojure.string :as str])) (defn settings-local-path [] ".claude/settings.local.json") @@ -39,16 +40,34 @@ :hooks [{:type "command" :command (str "brepl hook eval" debug-flag) :continueOnError (not (:strict-eval opts))}]}] + :Stop [{:matcher "" + :hooks [{:type "command" + :command (str "brepl hook stop" debug-flag)}]}] :SessionEnd [{:matcher "*" :hooks [{:type "command" :command "brepl hook session-end"}]}]})) +(defn brepl-hook? + "Check if a hook entry belongs to brepl." + [hook-entry] + (some #(str/starts-with? (:command %) "brepl hook") + (:hooks hook-entry))) + +(defn merge-hook-event + "Merge new brepl entries with existing non-brepl entries for a single event." + [existing-entries new-entries] + (let [non-brepl (remove brepl-hook? existing-entries)] + (into (vec non-brepl) new-entries))) + (defn merge-hooks - "Merge new hooks with existing ones, avoiding duplicates." + "Merge brepl hooks with existing hooks, preserving non-brepl hooks." [existing-hooks new-hooks] - ;; For now, just replace with new hooks - ;; In production, would do smarter merging - new-hooks) + (reduce-kv + (fn [acc event-name new-entries] + (let [existing-entries (get acc event-name [])] + (assoc acc event-name (merge-hook-event existing-entries new-entries)))) + existing-hooks + new-hooks)) (defn find-brepl-resources "Find the brepl resources directory." @@ -86,17 +105,66 @@ {:success true :message "Skill removed from .claude/skills/brepl"}) {:success true :message "Skill not found (already uninstalled)"}))) +(def hooks-template + ";; brepl stop hooks configuration + +{:stop + [;; Example: Run tests via nREPL after Claude stops + ;; {:type :repl + ;; :code (clojure.test/run-tests) + ;; :required? true ; Must pass - Claude retries until success + ;; :max-retries 10 ; Give up after 10 attempts (0 = infinite) + ;; :timeout 120} + + ;; Example: Run linter via bash + ;; {:type :bash + ;; :command \"clj-kondo --lint src\" + ;; :required? false ; Optional - inform on failure but don't retry + ;; :timeout 30} + ]} + +;; Hook fields: +;; :type - :repl or :bash (required) +;; :required? - if true: must pass, retry on failure (default: false) +;; :max-retries - max retry attempts, 0 = infinite (default: 10) +;; :timeout - seconds before timeout (default: 60) +;; +;; REPL hooks: +;; :code - Clojure code as s-expression or string (required) +;; +;; Bash hooks: +;; :command - shell command to run (required) +;; :cwd - working directory (default: \".\") +;; :env - environment variables map (default: {}) +") + +(defn generate-hooks-template + "Generate .brepl/hooks.edn template if it doesn't exist." + [] + (let [brepl-dir ".brepl" + hooks-file (str brepl-dir "/hooks.edn")] + (if (fs/exists? hooks-file) + {:created false :message "hooks.edn already exists"} + (do + (fs/create-dirs brepl-dir) + (spit hooks-file hooks-template) + {:created true :message "Created .brepl/hooks.edn template"})))) + (defn install-hooks "Install brepl hooks to .claude/settings.local.json and brepl skill." [opts] (let [settings (read-settings) + existing-hooks (get settings :hooks {}) new-hooks (brepl-hook-config opts) - updated-settings (assoc settings :hooks new-hooks)] + merged-hooks (merge-hooks existing-hooks new-hooks) + updated-settings (assoc settings :hooks merged-hooks)] (write-settings updated-settings) - (let [skill-result (install-skill)] - (if (:success skill-result) - {:success true :message "Hooks and skill installed successfully"} - {:success true :message (str "Hooks installed. " (:message skill-result))})))) + (let [skill-result (install-skill) + template-result (generate-hooks-template)] + {:success true + :message (str "Hooks installed successfully" + (when (:created template-result) + ". Created .brepl/hooks.edn template"))}))) (defn uninstall-hooks "Remove brepl hooks from .claude/settings.local.json." diff --git a/lib/stop_hooks.clj b/lib/stop_hooks.clj new file mode 100644 index 0000000..1c83dd0 --- /dev/null +++ b/lib/stop_hooks.clj @@ -0,0 +1,333 @@ +(ns brepl.lib.stop-hooks + "Stop hook execution for Claude Code Stop event." + (:require [clojure.java.io :as io] + [clojure.edn :as edn] + [clojure.spec.alpha :as s] + [clojure.string :as str] + [babashka.process :as process])) + +;; ============================================================================= +;; Specs for hook validation +;; ============================================================================= + +;; Common fields (same for REPL and bash) +(s/def ::type #{:repl :bash}) +(s/def ::name string?) +(s/def ::required? boolean?) ;; If true: retry on failure. If false: inform and proceed. +(s/def ::max-retries (s/and int? (complement neg?))) +(s/def ::timeout pos-int?) + +;; REPL-specific - code can be string or s-expression +(s/def ::code (s/or :string string? :form list? :symbol symbol?)) + +;; Bash-specific +(s/def ::command string?) +(s/def ::cwd string?) +(s/def ::env (s/map-of string? string?)) + +;; Hook specs by type +(s/def ::repl-hook + (s/keys :req-un [::type ::code] + :opt-un [::name ::required? ::max-retries ::timeout])) + +(s/def ::bash-hook + (s/keys :req-un [::type ::command] + :opt-un [::name ::required? ::max-retries ::timeout ::cwd ::env])) + +(s/def ::hook + (s/or :repl (s/and ::repl-hook #(= (:type %) :repl)) + :bash (s/and ::bash-hook #(= (:type %) :bash)))) + +(s/def ::stop (s/coll-of ::hook)) + +(s/def ::hooks-config + (s/keys :opt-un [::stop])) + +;; ============================================================================= +;; Default values +;; ============================================================================= + +(def defaults + {:required? false + :max-retries 10 + :timeout 60 + :cwd "." + :env {}}) + +(defn derive-name + "Derive hook name from command or code if not provided." + [hook] + (or (:name hook) + (let [raw (or (:command hook) (:code hook) "hook") + source (if (string? raw) raw (pr-str raw)) + truncated (subs source 0 (min 30 (count source)))] + (if (< (count source) 30) truncated (str truncated "..."))))) + +(defn apply-defaults + "Apply default values to a hook." + [hook] + (-> (merge defaults hook) + (assoc :name (derive-name hook)))) + +;; ============================================================================= +;; Configuration loading +;; ============================================================================= + +(def hooks-file ".brepl/hooks.edn") + +(defn load-hooks + "Load and parse .brepl/hooks.edn. Returns {:stop [...]} or nil if not found." + [] + (let [f (io/file hooks-file)] + (when (.exists f) + (try + (edn/read-string (slurp f)) + (catch Exception e + {:error (.getMessage e)}))))) + +;; ============================================================================= +;; Validation +;; ============================================================================= + +(defn validate-hooks + "Validate hooks config against specs. + Returns {:valid? true/false :errors [...]}." + [config] + (if (s/valid? ::hooks-config config) + {:valid? true :errors []} + {:valid? false + :errors (s/explain-data ::hooks-config config)})) + +;; ============================================================================= +;; State persistence (retry tracking) +;; ============================================================================= + +(defn state-file-path + "Get path to state file for a session." + [session-id] + (str "/tmp/brepl-stop-hook-" session-id ".edn")) + +(defn read-state + "Read retry state from file. Returns map of hook-name -> retry-count." + [session-id] + (let [f (io/file (state-file-path session-id))] + (if (.exists f) + (try + (edn/read-string (slurp f)) + (catch Exception _ + {})) + {}))) + +(defn write-state + "Write retry state to file." + [session-id state] + (spit (state-file-path session-id) (pr-str state))) + +(defn cleanup-state + "Remove state file." + [session-id] + (let [f (io/file (state-file-path session-id))] + (when (.exists f) + (.delete f)))) + +;; ============================================================================= +;; Bash hook execution +;; ============================================================================= + +(defn execute-bash-hook + "Execute shell command via babashka.process. + Returns {:success? bool :stdout str :stderr str :exit int}." + [hook] + (let [{:keys [command cwd env timeout]} (apply-defaults hook) + timeout-ms (* timeout 1000)] + (try + ;; Use sh -c to run command through shell for proper expansion + (let [result (process/shell {:out :string + :err :string + :dir cwd + :extra-env env + :timeout timeout-ms + :continue true} ;; Don't throw on non-zero exit + "sh" "-c" command)] + {:success? (zero? (:exit result)) + :stdout (:out result) + :stderr (:err result) + :exit (:exit result)}) + (catch java.util.concurrent.TimeoutException _ + {:success? false + :stdout "" + :stderr (str "Hook timed out after " timeout " seconds") + :exit -1}) + (catch Exception e + {:success? false + :stdout "" + :stderr (.getMessage e) + :exit -1})))) + +;; ============================================================================= +;; REPL hook execution +;; ============================================================================= + +;; Forward declaration - will be set by brepl main when loading +(def ^:dynamic *nrepl-eval-fn* nil) +(def ^:dynamic *resolve-port-fn* nil) +(def ^:dynamic *resolve-host-fn* nil) + +(defn connection-error? + "Check if error indicates nREPL server is not running." + [error-str] + (and error-str + (or (str/includes? error-str "Connection refused") + (str/includes? error-str "Connection reset")))) + +(defn execute-repl-hook + "Execute Clojure code via nREPL. + Returns {:success? bool :output str :error str :no-server? bool}." + [hook] + (let [{:keys [code timeout required?]} (apply-defaults hook) + ;; Code can be string or s-expression - convert to string for nREPL + code-str (if (string? code) code (pr-str code)) + port (when *resolve-port-fn* (*resolve-port-fn* nil nil)) + host (when *resolve-host-fn* (*resolve-host-fn* nil))] + (cond + ;; No port available + (nil? port) + (if required? + {:success? false + :output "" + :error "Inform the user that the hook won't work because nREPL server is not running. Offer to start it or ask the user to do it." + :no-server? true} + {:success? true + :output "" + :error nil + :skipped? true}) + + ;; Have port, execute + *nrepl-eval-fn* + (try + (let [timeout-ms (* timeout 1000) + result-future (future (*nrepl-eval-fn* host port code-str {:hook true})) + result (deref result-future timeout-ms ::timeout)] + (if (= result ::timeout) + {:success? false + :output "" + :error (str "Hook timed out after " timeout " seconds")} + (let [{:keys [processed has-error?]} result + output (str/join "\n" (concat (:out processed) (:values processed))) + error-msg (when has-error? + (or (:ex processed) + (str/join "\n" (:err processed)) + "Evaluation error"))] + ;; Check for connection errors (stale .nrepl-port file) + (if (connection-error? error-msg) + {:success? false + :output "" + :error "Inform the user that the hook won't work because nREPL server is not running. Offer to start it or ask the user to do it." + :no-server? true} + {:success? (not has-error?) + :output output + :error error-msg})))) + (catch Exception e + (let [msg (.getMessage e)] + (if (connection-error? msg) + {:success? false + :output "" + :error "Inform the user that the hook won't work because nREPL server is not running. Offer to start it or ask the user to do it." + :no-server? true} + {:success? false + :output "" + :error msg})))) + + :else + {:success? false + :output "" + :error "nREPL eval function not initialized"}))) + +;; ============================================================================= +;; Main orchestration +;; ============================================================================= + +(defn execute-hook + "Execute a single hook (REPL or bash). + Returns {:success? bool :output str :error str :no-server? bool}." + [hook] + (case (:type hook) + :repl (execute-repl-hook hook) ;; Returns :no-server? when nREPL unavailable + :bash (let [result (execute-bash-hook hook) + stdout (str/trim (or (:stdout result) "")) + stderr (str/trim (or (:stderr result) "")) + ;; Combine output for error context (Claude needs to see what failed) + combined-output (str/join "\n" (remove str/blank? [stdout stderr]))] + {:success? (:success? result) + :output stdout + :error (when-not (:success? result) + (if (str/blank? combined-output) + (str "Exit code " (:exit result)) + (str "Exit code " (:exit result) "\n" combined-output)))}))) + +(defn run-stop-hooks + "Main orchestration function. + Takes session-id and hooks config. + Returns {:exit-code 0|1|2 :message str}." + [session-id config] + (let [hooks (mapv apply-defaults (get config :stop []))] + (if (empty? hooks) + {:exit-code 0 :message "No hooks configured"} + (let [state (read-state session-id)] + (loop [remaining hooks + current-state state] + (if (empty? remaining) + ;; All hooks passed + (do + (cleanup-state session-id) + {:exit-code 0 :message "All hooks passed"}) + (let [hook (first remaining) + hook-name (:name hook) + result (execute-hook hook)] + (cond + ;; Hook passed - reset retry count, continue + (:success? result) + (recur (rest remaining) + (dissoc current-state hook-name)) + + ;; No server - first time: block (exit 2), second time: inform (exit 1) + (:no-server? result) + (let [no-server-key "__no-server__" + seen-before? (contains? current-state no-server-key)] + (if seen-before? + ;; Second attempt - allow stopping + (do + (cleanup-state session-id) + {:exit-code 1 + :message (:error result)}) + ;; First attempt - block, force Claude to react + (do + (write-state session-id (assoc current-state no-server-key true)) + {:exit-code 2 + :message (:error result)}))) + + ;; Hook failed - check retry logic + :else + (let [required? (:required? hook) + max-retries (:max-retries hook) + current-retries (get current-state hook-name 0) + new-retries (inc current-retries) + infinite-retries? (zero? max-retries) + within-limit? (or infinite-retries? (< current-retries max-retries))] + (if (and required? within-limit?) + ;; Required hook - retry (exit 2 to make Claude continue) + (do + (write-state session-id (assoc current-state hook-name new-retries)) + {:exit-code 2 + :message (str "Hook '" hook-name "' failed (attempt " new-retries + (when-not infinite-retries? + (str "/" max-retries)) + "). Fix the issues shown below and try again:\n" + (:error result))}) + ;; Optional hook or limit reached - inform (exit 1) + (do + (write-state session-id (dissoc current-state hook-name)) + {:exit-code 1 + :message (if (and required? (not within-limit?)) + (str "Hook '" hook-name "' failed after " max-retries " retries: " (:error result)) + (str "Hook '" hook-name "' failed: " (:error result)))}))))))))))) \ No newline at end of file diff --git a/openspec/changes/archive/2026-01-04-add-stop-hooks/design.md b/openspec/changes/archive/2026-01-04-add-stop-hooks/design.md new file mode 100644 index 0000000..b60e13b --- /dev/null +++ b/openspec/changes/archive/2026-01-04-add-stop-hooks/design.md @@ -0,0 +1,219 @@ +# Design: Stop Hooks Implementation + +## Idempotent Settings Sync + +### Problem + +When running `brepl hook install`, we need to: + +1. Add/update brepl hooks in `.claude/settings.local.json` +2. Preserve non-brepl hooks added by user or other tools +3. Be idempotent (running multiple times produces same result) + +### Solution: Hook Identification and Merge + +**Identify brepl hooks** by command prefix: + +- All brepl hooks have commands starting with `brepl hook` +- Filter out existing brepl hooks before adding new ones + +**Merge strategy**: + +``` +1. Read existing settings +2. For each hook event (PreToolUse, PostToolUse, Stop, SessionEnd): + a. Filter out entries where any hook command starts with "brepl hook" + b. Add new brepl hook entries +3. Write merged settings +``` + +**Implementation** (in `lib/installer.clj`): + +```clojure +(defn brepl-hook? [hook-entry] + "Check if a hook entry belongs to brepl." + (some #(str/starts-with? (:command %) "brepl hook") + (:hooks hook-entry))) + +(defn merge-hook-event [existing-entries new-entries] + "Merge new brepl entries with existing non-brepl entries." + (let [non-brepl (remove brepl-hook? existing-entries)] + (into (vec non-brepl) new-entries))) + +(defn merge-hooks [existing-hooks new-hooks] + "Merge brepl hooks with existing hooks, preserving non-brepl hooks." + (reduce-kv + (fn [acc event-name new-entries] + (let [existing-entries (get acc event-name [])] + (assoc acc event-name (merge-hook-event existing-entries new-entries)))) + existing-hooks + new-hooks)) +``` + +### Example + +**Before** (existing settings): + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write", + "hooks": [{ "type": "command", "command": "prettier --write" }] + }, + { + "matcher": "Edit", + "hooks": [{ "type": "command", "command": "brepl hook eval" }] + } + ] + } +} +``` + +**After** `brepl hook install`: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [{ "type": "command", "command": "brepl hook validate" }] + } + ], + "PostToolUse": [ + { + "matcher": "Write", + "hooks": [{ "type": "command", "command": "prettier --write" }] + }, + { + "matcher": "Edit|Write", + "hooks": [{ "type": "command", "command": "brepl hook eval" }] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [{ "type": "command", "command": "brepl hook stop" }] + } + ], + "SessionEnd": [ + { + "matcher": "*", + "hooks": [{ "type": "command", "command": "brepl hook session-end" }] + } + ] + } +} +``` + +Note: The old brepl PostToolUse entry (matching only "Edit") was replaced with the new one (matching "Edit|Write"), while the prettier hook was preserved. + +## Template Generation + +### `.brepl/hooks.edn` Template + +```clojure +;; brepl stop hooks configuration + +{:stop + [;; Example: Run tests via nREPL after Claude stops + ;; {:type :repl + ;; :name "run-tests" + ;; :code "(clojure.test/run-tests)" + ;; :retry-on-failure? true ; Claude keeps trying until tests pass + ;; :max-retries 10 ; Give up after 10 attempts (0 = infinite) + ;; :required? true ; Inform user if no nREPL connection + ;; :timeout 120} + + ;; Example: Run linter via bash + ;; {:type :bash + ;; :name "lint" + ;; :command "clj-kondo --lint src" + ;; :retry-on-failure? false ; Report failure but don't retry + ;; :timeout 30 + ;; :cwd "." + ;; :env {"CI" "true"}} + ]} + +;; Hook fields: +;; :type - :repl or :bash (required) +;; :name - identifier for error messages (required) +;; :retry-on-failure? - if true and fails, Claude retries (default: false) +;; :max-retries - max retry attempts, 0 = infinite (default: 10) +;; :required? - if true and can't run, inform user (default: false) +;; :timeout - seconds before timeout (default: 60) +;; +;; REPL-specific: +;; :code - Clojure code to evaluate (required for :repl) +;; +;; Bash-specific: +;; :command - shell command to run (required for :bash) +;; :cwd - working directory (default: ".") +;; :env - environment variables map (default: {}) +``` + +## Exit Code Semantics + +Per Claude Code Stop hook behavior: + +- **Exit 0** = Success, Claude can stop +- **Exit 1** = Informational error (Claude sees stderr, can stop) +- **Exit 2** = Blocking error (Claude must continue working) + +**All hooks pass**: + +``` +exit 0 +``` + +**Hook fails with :loop-on-failure? true (retry count < max)**: + +``` +stderr: "Hook 'run-tests' failed: 3 tests failed" +exit 2 +``` + +Claude continues working to fix the issue. + +**Hook fails with :loop-on-failure? true (retry limit reached)**: + +``` +stderr: "Hook 'run-tests' failed after 10 retries. Giving up." +exit 1 +``` + +Claude is informed but can stop. + +**Hook fails with :loop-on-failure? false**: + +``` +stderr: "Hook 'lint' failed: 2 warnings found" +exit 1 +``` + +Claude is informed but can stop. + +**Required hook can't run (no nREPL)**: + +``` +stderr: "Hook 'run-tests' requires nREPL but none available. Please start REPL and retry." +exit 1 +``` + +Claude is informed, should pause and notify user. + +## State Persistence + +Retry counts tracked in `/tmp/brepl-stop-hook-{session_id}.edn`: + +```clojure +{"run-tests" 3 + "lint" 0} +``` + +- Created on first hook failure +- Updated on each retry +- Reset to 0 on success +- Deleted by SessionEnd hook or when all hooks pass diff --git a/openspec/changes/archive/2026-01-04-add-stop-hooks/proposal.md b/openspec/changes/archive/2026-01-04-add-stop-hooks/proposal.md new file mode 100644 index 0000000..72f301a --- /dev/null +++ b/openspec/changes/archive/2026-01-04-add-stop-hooks/proposal.md @@ -0,0 +1,51 @@ +# Proposal: Add Stop Hook Customization + +## Problem Statement + +Developers using brepl with Claude Code need automated test runs and validation after Claude finishes making changes. Currently brepl handles PreToolUse (bracket validation) and PostToolUse (file evaluation), but there's no way to run custom code when Claude completes a response cycle. + +## Proposed Solution + +Add support for user-configurable stop hooks that run when Claude Code fires the Stop event. Hooks can execute Clojure code via nREPL or bash commands, with configurable behavior for blocking (making Claude continue working if tests fail). + +### Key Features + +1. **Configuration file** at `.brepl/hooks.edn` with `:stop` key containing hook definitions +2. **Two hook types**: `:repl` (execute Clojure via nREPL) and `:bash` (shell commands) +3. **Blocking behavior**: Hooks can block Claude from stopping if validation fails +4. **Loop-on-failure**: Configurable per-hook to make Claude keep trying until fixed +5. **Schema validation**: Use `clojure.spec.alpha` for config validation +6. **Template generation**: `brepl hook install` creates example `.brepl/hooks.edn` + +## Impact Analysis + +### Files Modified + +- `brepl` - Add `handle-stop` function and stop hook execution logic +- `lib/installer.clj` - Update to generate Stop hook config and template `.brepl/hooks.edn` + +### Files Added + +- `lib/stop_hooks.clj` - Stop hook loading, validation, and execution +- Template content for `.brepl/hooks.edn` + +### Dependencies + +- No new external dependencies +- Uses existing nREPL infrastructure from brepl +- Uses babashka.process for bash execution (already available in bb) + +## Alternatives Considered + +1. **Extend existing PostToolUse hooks** - Rejected because Stop event fires once per response cycle, not per tool use +2. **Separate config file per hook type** - Rejected for simplicity; single `.brepl/hooks.edn` is cleaner +3. **YAML/JSON config format** - Rejected; EDN is idiomatic for Clojure projects + +## Success Criteria + +- User can define stop hooks in `.brepl/hooks.edn` +- REPL hooks execute Clojure code and report results +- Bash hooks execute shell commands and report results +- Failed blocking hooks with `:loop-on-failure? true` cause Claude to continue working +- `brepl hook install` generates template config file +- Spec validation catches invalid hook configurations diff --git a/openspec/changes/archive/2026-01-04-add-stop-hooks/specs/stop-hooks/spec.md b/openspec/changes/archive/2026-01-04-add-stop-hooks/specs/stop-hooks/spec.md new file mode 100644 index 0000000..c06fb24 --- /dev/null +++ b/openspec/changes/archive/2026-01-04-add-stop-hooks/specs/stop-hooks/spec.md @@ -0,0 +1,420 @@ +# stop-hooks Specification + +## Purpose + +Enable user-configurable hooks that execute when Claude Code fires the Stop event, allowing automated test runs, validation checks, and cleanup operations after Claude finishes responding. + +## ADDED Requirements + +### Requirement: Stop Hook Configuration File + +The system SHALL load stop hook definitions from `.brepl/hooks.edn` in the current working directory. + +#### Scenario: Load valid configuration + +```gherkin +Given Maya has created .brepl/hooks.edn with valid hook definitions +And the file contains a :stop key with a vector of hook maps +When brepl hook stop executes +Then the hooks are loaded and executed in order +``` + +#### Scenario: No configuration file exists + +```gherkin +Given no .brepl/hooks.edn file exists in the current directory +When brepl hook stop executes +Then brepl returns success with no hooks executed +And outputs {"decision": "approve"} +``` + +#### Scenario: Invalid configuration format + +```gherkin +Given .brepl/hooks.edn contains malformed EDN or invalid hook structure +When brepl hook stop executes +Then brepl exits with code 1 +And outputs error to stderr +And Claude is informed but can stop +``` + +The configuration file: + +- Location: `.brepl/hooks.edn` (CWD only, no parent directory traversal) +- Format: EDN with `:stop` key containing vector of hook maps +- Validation: Uses `clojure.spec.alpha` to validate structure + +### Requirement: Hook Schema Validation + +The system SHALL validate hook definitions using clojure.spec.alpha before execution. + +#### Scenario: Valid REPL hook + +```gherkin +Given a hook map with :type :repl, :name "tests", and :code "(run-tests)" +When the schema is validated +Then the hook is accepted +``` + +#### Scenario: Valid bash hook + +```gherkin +Given a hook map with :type :bash, :name "lint", and :command "clj-kondo --lint src" +When the schema is validated +Then the hook is accepted +``` + +#### Scenario: Missing required field + +```gherkin +Given a hook map missing the :name field +When the schema is validated +Then validation fails with explanation of missing field +``` + +#### Scenario: Invalid field type + +```gherkin +Given a hook map with :timeout "sixty" (string instead of integer) +When the schema is validated +Then validation fails with explanation of type mismatch +``` + +Common hook fields (all hooks): + +- `:type` - keyword, `:repl` or `:bash` (required) +- `:name` - string, identifier for reporting (required) +- `:retry-on-failure?` - boolean, if true and hook fails, exit 2 to make Claude retry (default: false) +- `:max-retries` - non-negative integer, max retry attempts before giving up; 0 means infinite (default: 10) +- `:required?` - boolean, if true and hook can't run, inform Claude to pause and notify user (default: false) + - For REPL hooks: REPL must be available + - For bash hooks: command must be able to execute +- `:timeout` - positive integer, seconds before timeout (default: 60, no max - user decides based on their test suite) + +REPL-specific fields: + +- `:code` - string, Clojure code to evaluate (required for :repl) + +Bash-specific fields: + +- `:command` - string, shell command to execute (required for :bash) +- `:cwd` - string, working directory (default: ".") +- `:env` - map of string to string, environment variables (default: {}) + +### Requirement: REPL Hook Execution + +The system SHALL execute REPL hooks by evaluating Clojure code via nREPL connection. + +#### Scenario: Successful REPL hook execution + +```gherkin +Given Dev has configured a REPL hook with :code "(+ 1 2)" +And an nREPL server is running +When the stop hook executes +Then the code is evaluated via nREPL +And the hook is marked as successful +``` + +#### Scenario: REPL hook with evaluation error + +```gherkin +Given Dev has configured a REPL hook with :code "(/ 1 0)" +And an nREPL server is running +When the stop hook executes +Then the hook is marked as failed +And the error message is captured for reporting +``` + +#### Scenario: No nREPL available with required hook + +```gherkin +Given Dev has configured a REPL hook with :required? true +And no nREPL server is running +When the stop hook executes +Then brepl exits with code 1 +And outputs message asking Claude to pause and notify user +And Claude is informed but can stop +``` + +#### Scenario: No nREPL available with optional hook + +```gherkin +Given Dev has configured a REPL hook with :required? false (or omitted) +And no nREPL server is running +When the stop hook executes +Then the hook is skipped silently +And execution continues with remaining hooks +``` + +REPL hook execution: + +- Uses existing brepl nREPL infrastructure +- Port resolution follows existing priority (CLI > .nrepl-port > env var) +- Timeout enforced per hook +- Captures stdout, stderr, and evaluation result + +### Requirement: Bash Hook Execution + +The system SHALL execute bash hooks by running shell commands via babashka.process. + +#### Scenario: Successful bash hook execution + +```gherkin +Given Dev has configured a bash hook with :command "echo hello" +When the stop hook executes +Then the command runs in a shell +And exit code 0 marks the hook as successful +``` + +#### Scenario: Bash hook with non-zero exit + +```gherkin +Given Dev has configured a bash hook with :command "exit 1" +When the stop hook executes +Then the hook is marked as failed +And stdout/stderr are captured for reporting +``` + +#### Scenario: Bash hook with custom working directory + +```gherkin +Given Dev has configured a bash hook with :cwd "test" +When the stop hook executes +Then the command runs with working directory set to "test" +``` + +#### Scenario: Bash hook with environment variables + +```gherkin +Given Dev has configured a bash hook with :env {"CI" "true", "DEBUG" "1"} +When the stop hook executes +Then the command runs with those environment variables set +``` + +Bash hook execution: + +- Uses babashka.process/shell +- Inherits current environment, merges with :env +- Timeout enforced per hook +- Captures stdout, stderr, and exit code + +### Requirement: Sequential Execution with Failure Handling + +The system SHALL execute hooks sequentially in definition order with configurable failure behavior. + +#### Scenario: All hooks succeed + +```gherkin +Given Dev has configured three hooks in .brepl/hooks.edn +And all hooks execute successfully +When brepl hook stop runs +Then all hooks execute in order +And brepl outputs {"decision": "approve"} +``` + +#### Scenario: Blocking hook fails with loop-on-failure + +```gherkin +Given Dev has configured a hook with :retry-on-failure? true +And the hook fails (test failure, non-zero exit, etc.) +And retry count is below :max-retries +When brepl hook stop runs +Then brepl exits with code 2 to force Claude to continue +And error details go to stderr +And subsequent hooks do not execute +``` + +#### Scenario: Blocking hook exhausts retry limit + +```gherkin +Given Dev has configured a hook with :retry-on-failure? true and :max-retries 10 +And the hook has failed 10 times +When brepl hook stop runs +Then brepl exits with code 1 +And outputs message that retry limit reached +And Claude is informed but can stop +``` + +#### Scenario: Blocking hook with infinite retries + +```gherkin +Given Dev has configured a hook with :retry-on-failure? true and :max-retries 0 +And the hook fails +When brepl hook stop runs +Then brepl exits with code 2 to force Claude to continue +And this continues indefinitely until hook passes +``` + +#### Scenario: Non-looping hook fails + +```gherkin +Given Dev has configured a hook with :retry-on-failure? false (or omitted) +And the hook fails +When brepl hook stop runs +Then brepl exits with code 1 +And failure is reported to stderr +And Claude is informed but can stop +``` + +Execution behavior: + +- Hooks execute in order defined in config +- Hook failure with `:retry-on-failure? true` exits 2 (Claude retries) until `:max-retries` reached +- Hook failure with `:retry-on-failure? false` exits 1 (Claude informed, can stop) +- Retry count tracked per hook per session in state file + +### Requirement: State Persistence for Retry Tracking + +The system SHALL persist retry counts across hook invocations using a state file in /tmp. + +#### Scenario: Track retry count across invocations + +```gherkin +Given Dev has configured a hook with :retry-on-failure? true +And the hook fails on first invocation +When Claude retries and brepl hook stop runs again +Then brepl reads the previous retry count from state file +And increments the count for this hook +``` + +#### Scenario: Reset retry count on success + +```gherkin +Given a hook has failed 5 times previously +And the hook now succeeds +When brepl hook stop completes +Then the retry count for that hook is reset to 0 +``` + +#### Scenario: Isolate state by session + +```gherkin +Given two Claude sessions are running in the same directory +When each session runs brepl hook stop +Then each session has its own independent retry counts +``` + +State file: + +- Location: `/tmp/brepl-stop-hook-{session_id}.edn` +- Format: EDN map of hook name to retry count +- Cleanup: Removed by SessionEnd hook or on success + +### Requirement: Claude Code Stop Event Integration + +The system SHALL integrate with Claude Code's Stop hook event via `brepl hook stop` command. + +#### Scenario: Receive stop event input + +```gherkin +Given Claude Code fires the Stop event +When brepl hook stop receives JSON input via stdin +Then it parses session_id, transcript_path, and other fields +And proceeds with hook execution +``` + +#### Scenario: All hooks pass + +```gherkin +Given all hooks execute successfully +When brepl hook stop completes +Then brepl exits with code 0 +And Claude can stop +``` + +#### Scenario: Hook fails with loop-on-failure + +```gherkin +Given a hook with :retry-on-failure? true fails +And retry count is below :max-retries +When brepl hook stop completes +Then brepl exits with code 2 +And error details go to stderr +And Claude must continue working +``` + +#### Scenario: Hook fails without loop-on-failure + +```gherkin +Given a hook with :retry-on-failure? false fails +When brepl hook stop completes +Then brepl exits with code 1 +And error details go to stderr +And Claude is informed but can stop +``` + +CLI interface: + +- Command: `brepl hook stop` +- Input: Claude Code Stop event JSON via stdin +- Output: Error messages to stderr +- Exit codes: + - 0 = success, Claude can stop + - 1 = informational error, Claude is informed but can stop + - 2 = blocking error, Claude must continue working + +### Requirement: Install Command Enhancement + +The system SHALL generate a template `.brepl/hooks.edn` file and register Stop hook in Claude settings. + +#### Scenario: Install creates template config + +```gherkin +Given no .brepl/hooks.edn exists +When Dev runs brepl hook install +Then .brepl/hooks.edn is created with commented examples +And the Stop hook is registered in .claude/settings.local.json +``` + +#### Scenario: Install preserves existing brepl config + +```gherkin +Given .brepl/hooks.edn already exists with user configuration +When Dev runs brepl hook install +Then the existing .brepl/hooks.edn is not modified +And the Stop hook is registered in .claude/settings.local.json +``` + +Template content includes: + +- Commented example REPL hook for running tests +- Commented example bash hook for linting +- Documentation of all available fields +- Explanation of blocking vs non-blocking behavior + +### Requirement: Idempotent Claude Settings Merge + +The system SHALL merge brepl hooks with existing Claude settings without replacing non-brepl hooks. + +#### Scenario: Preserve non-brepl hooks during install + +```gherkin +Given .claude/settings.local.json contains a PostToolUse hook for prettier +When Dev runs brepl hook install +Then the prettier hook is preserved +And brepl hooks are added alongside it +``` + +#### Scenario: Update brepl hooks idempotently + +```gherkin +Given .claude/settings.local.json contains older brepl hook configuration +When Dev runs brepl hook install +Then the old brepl hooks are replaced with new configuration +And non-brepl hooks remain unchanged +``` + +#### Scenario: Install is idempotent + +```gherkin +Given Dev has already run brepl hook install +When Dev runs brepl hook install again +Then the settings file contains the same configuration +And no duplicate hooks are created +``` + +Hook identification: + +- Brepl hooks are identified by commands starting with "brepl hook" +- Non-brepl hooks (commands not starting with "brepl hook") are preserved +- Each install replaces all brepl hooks with current configuration diff --git a/openspec/changes/archive/2026-01-04-add-stop-hooks/tasks.md b/openspec/changes/archive/2026-01-04-add-stop-hooks/tasks.md new file mode 100644 index 0000000..e855af8 --- /dev/null +++ b/openspec/changes/archive/2026-01-04-add-stop-hooks/tasks.md @@ -0,0 +1,157 @@ +# Tasks: Add Stop Hooks + +## Implementation Tasks + +### 1. Create stop hooks library with spec validation + +**File**: `lib/stop_hooks.clj` + +- [x] Define clojure.spec.alpha specs for hook schema +- [x] Implement `load-hooks` to read and parse `.brepl/hooks.edn` +- [x] Implement `validate-hooks` to check against specs +- [x] Return helpful error messages for validation failures + +**Verification**: Unit tests for spec validation with valid/invalid configs ✓ + +### 2. Implement REPL hook execution + +**File**: `lib/stop_hooks.clj` + +- [x] Implement `execute-repl-hook` using existing nREPL infrastructure +- [x] Handle timeout via future/deref with timeout +- [x] Handle missing nREPL based on `:required?` flag +- [x] Capture and return stdout, stderr, result, error + +**Verification**: Unit tests with mock nREPL responses ✓ + +### 3. Implement bash hook execution + +**File**: `lib/stop_hooks.clj` + +- [x] Implement `execute-bash-hook` using babashka.process +- [x] Support `:cwd` and `:env` options +- [x] Handle timeout +- [x] Capture and return stdout, stderr, exit code + +**Verification**: Unit tests with simple shell commands ✓ + +### 4. Implement state persistence for retry tracking + +**File**: `lib/stop_hooks.clj` + +- [x] Implement `read-state` to load `/tmp/brepl-stop-hook-{session_id}.edn` +- [x] Implement `write-state` to save retry counts +- [x] Implement `cleanup-state` to remove state file on success +- [x] Track retry count per hook name + +**Verification**: Unit tests for state file read/write/cleanup ✓ + +### 5. Implement sequential execution with failure handling + +**File**: `lib/stop_hooks.clj` + +- [x] Implement `run-stop-hooks` orchestrating hook execution +- [x] Execute hooks in order +- [x] Check retry count against `:max-retries` for `:loop-on-failure?` hooks +- [x] Return appropriate exit code (0, 1, or 2) + +**Verification**: Integration tests with mixed success/failure hooks ✓ + +### 6. Add handle-stop CLI handler + +**File**: `brepl` + +- [x] Add `handle-stop` function to parse stdin JSON (get session_id) +- [x] Wire up to `brepl hook stop` subcommand +- [x] Output errors to stderr +- [x] Exit with 0 (success), 1 (inform), or 2 (block) + +**Verification**: Manual test with `echo '{"session_id":"test"}' | brepl hook stop` ✓ + +### 7. Implement idempotent hook merging + +**File**: `lib/installer.clj` + +- [x] Add `brepl-hook?` predicate to identify brepl hooks by command prefix +- [x] Add `merge-hook-event` to filter out old brepl hooks before adding new +- [x] Update `merge-hooks` to use new merge strategy +- [x] Add Stop hook to `brepl-hook-config` + +**Verification**: Install with existing non-brepl hooks, verify they're preserved ✓ + +### 8. Add template generation for .brepl/hooks.edn + +**File**: `lib/installer.clj` + +- [x] Create `.brepl` directory if needed +- [x] Generate `.brepl/hooks.edn` template with commented examples +- [x] Skip template generation if file already exists + +**Verification**: Run `brepl hook install` and check generated files ✓ + +### 9. Add hook help subcommand + +**File**: `brepl` + +- [x] Update `show-hook-help` with stop subcommand documentation + +**Verification**: `brepl hook --help` shows stop command ✓ + +## Testing Tasks + +### 10. Add stop hooks spec validation tests + +**File**: `test/stop_hooks_test.clj` + +- [x] Test valid REPL hook passes validation +- [x] Test valid bash hook passes validation +- [x] Test missing required fields fail +- [x] Test invalid field types fail +- [x] Test unknown hook type fails + +### 11. Add stop hooks execution tests + +**File**: `test/stop_hooks_test.clj` + +- [x] Test successful REPL hook execution +- [x] Test REPL hook with eval error +- [x] Test successful bash hook execution +- [x] Test bash hook with non-zero exit +- [x] Test timeout handling +- [x] Test sequential execution order +- [x] Test blocking failure with loop-on-failure +- [x] Test non-blocking failure continues execution + +### 12. Add integration test for brepl hook stop + +**File**: `test/stop_hooks_test.clj` (combined) + +- [x] Test end-to-end with sample config file +- [x] Test JSON output format +- [x] Test exit codes + +### 13. Add idempotent merge tests + +**File**: `test/installer_test.clj` + +- [x] Test non-brepl hooks are preserved +- [x] Test brepl hooks are replaced +- [x] Test running install twice produces same result + +## Documentation Tasks + +### 14. Minimal README update (optional) + +- [ ] Add brief mention of `brepl hook stop` in hook subcommands list (if any) +- [ ] No detailed documentation yet - feature is experimental + +## Effort Estimation (CHAI) + +| Dimension | Score | Notes | +| ----------------- | ----- | ---------------------------------------------- | +| Claude Complexity | 3 | New lib file, touches installer and main | +| Error Probability | 2 | Clear spec from interview, good test patterns | +| Human Attention | 2 | Straightforward feature, well-defined behavior | +| Iteration Risk | 2 | Clear acceptance criteria from interview | + +**Summary**: Medium complexity, low risk. Can proceed confidently. diff --git a/openspec/specs/stop-hooks/spec.md b/openspec/specs/stop-hooks/spec.md new file mode 100644 index 0000000..dfb7bcf --- /dev/null +++ b/openspec/specs/stop-hooks/spec.md @@ -0,0 +1,418 @@ +# stop-hooks Specification + +## Purpose +TBD - created by archiving change add-stop-hooks. Update Purpose after archive. +## Requirements +### Requirement: Stop Hook Configuration File + +The system SHALL load stop hook definitions from `.brepl/hooks.edn` in the current working directory. + +#### Scenario: Load valid configuration + +```gherkin +Given Maya has created .brepl/hooks.edn with valid hook definitions +And the file contains a :stop key with a vector of hook maps +When brepl hook stop executes +Then the hooks are loaded and executed in order +``` + +#### Scenario: No configuration file exists + +```gherkin +Given no .brepl/hooks.edn file exists in the current directory +When brepl hook stop executes +Then brepl returns success with no hooks executed +And outputs {"decision": "approve"} +``` + +#### Scenario: Invalid configuration format + +```gherkin +Given .brepl/hooks.edn contains malformed EDN or invalid hook structure +When brepl hook stop executes +Then brepl exits with code 1 +And outputs error to stderr +And Claude is informed but can stop +``` + +The configuration file: + +- Location: `.brepl/hooks.edn` (CWD only, no parent directory traversal) +- Format: EDN with `:stop` key containing vector of hook maps +- Validation: Uses `clojure.spec.alpha` to validate structure + +### Requirement: Hook Schema Validation + +The system SHALL validate hook definitions using clojure.spec.alpha before execution. + +#### Scenario: Valid REPL hook + +```gherkin +Given a hook map with :type :repl, :name "tests", and :code "(run-tests)" +When the schema is validated +Then the hook is accepted +``` + +#### Scenario: Valid bash hook + +```gherkin +Given a hook map with :type :bash, :name "lint", and :command "clj-kondo --lint src" +When the schema is validated +Then the hook is accepted +``` + +#### Scenario: Missing required field + +```gherkin +Given a hook map missing the :name field +When the schema is validated +Then validation fails with explanation of missing field +``` + +#### Scenario: Invalid field type + +```gherkin +Given a hook map with :timeout "sixty" (string instead of integer) +When the schema is validated +Then validation fails with explanation of type mismatch +``` + +Common hook fields (all hooks): + +- `:type` - keyword, `:repl` or `:bash` (required) +- `:name` - string, identifier for reporting (required) +- `:retry-on-failure?` - boolean, if true and hook fails, exit 2 to make Claude retry (default: false) +- `:max-retries` - non-negative integer, max retry attempts before giving up; 0 means infinite (default: 10) +- `:required?` - boolean, if true and hook can't run, inform Claude to pause and notify user (default: false) + - For REPL hooks: REPL must be available + - For bash hooks: command must be able to execute +- `:timeout` - positive integer, seconds before timeout (default: 60, no max - user decides based on their test suite) + +REPL-specific fields: + +- `:code` - string, Clojure code to evaluate (required for :repl) + +Bash-specific fields: + +- `:command` - string, shell command to execute (required for :bash) +- `:cwd` - string, working directory (default: ".") +- `:env` - map of string to string, environment variables (default: {}) + +### Requirement: REPL Hook Execution + +The system SHALL execute REPL hooks by evaluating Clojure code via nREPL connection. + +#### Scenario: Successful REPL hook execution + +```gherkin +Given Dev has configured a REPL hook with :code "(+ 1 2)" +And an nREPL server is running +When the stop hook executes +Then the code is evaluated via nREPL +And the hook is marked as successful +``` + +#### Scenario: REPL hook with evaluation error + +```gherkin +Given Dev has configured a REPL hook with :code "(/ 1 0)" +And an nREPL server is running +When the stop hook executes +Then the hook is marked as failed +And the error message is captured for reporting +``` + +#### Scenario: No nREPL available with required hook + +```gherkin +Given Dev has configured a REPL hook with :required? true +And no nREPL server is running +When the stop hook executes +Then brepl exits with code 1 +And outputs message asking Claude to pause and notify user +And Claude is informed but can stop +``` + +#### Scenario: No nREPL available with optional hook + +```gherkin +Given Dev has configured a REPL hook with :required? false (or omitted) +And no nREPL server is running +When the stop hook executes +Then the hook is skipped silently +And execution continues with remaining hooks +``` + +REPL hook execution: + +- Uses existing brepl nREPL infrastructure +- Port resolution follows existing priority (CLI > .nrepl-port > env var) +- Timeout enforced per hook +- Captures stdout, stderr, and evaluation result + +### Requirement: Bash Hook Execution + +The system SHALL execute bash hooks by running shell commands via babashka.process. + +#### Scenario: Successful bash hook execution + +```gherkin +Given Dev has configured a bash hook with :command "echo hello" +When the stop hook executes +Then the command runs in a shell +And exit code 0 marks the hook as successful +``` + +#### Scenario: Bash hook with non-zero exit + +```gherkin +Given Dev has configured a bash hook with :command "exit 1" +When the stop hook executes +Then the hook is marked as failed +And stdout/stderr are captured for reporting +``` + +#### Scenario: Bash hook with custom working directory + +```gherkin +Given Dev has configured a bash hook with :cwd "test" +When the stop hook executes +Then the command runs with working directory set to "test" +``` + +#### Scenario: Bash hook with environment variables + +```gherkin +Given Dev has configured a bash hook with :env {"CI" "true", "DEBUG" "1"} +When the stop hook executes +Then the command runs with those environment variables set +``` + +Bash hook execution: + +- Uses babashka.process/shell +- Inherits current environment, merges with :env +- Timeout enforced per hook +- Captures stdout, stderr, and exit code + +### Requirement: Sequential Execution with Failure Handling + +The system SHALL execute hooks sequentially in definition order with configurable failure behavior. + +#### Scenario: All hooks succeed + +```gherkin +Given Dev has configured three hooks in .brepl/hooks.edn +And all hooks execute successfully +When brepl hook stop runs +Then all hooks execute in order +And brepl outputs {"decision": "approve"} +``` + +#### Scenario: Blocking hook fails with loop-on-failure + +```gherkin +Given Dev has configured a hook with :retry-on-failure? true +And the hook fails (test failure, non-zero exit, etc.) +And retry count is below :max-retries +When brepl hook stop runs +Then brepl exits with code 2 to force Claude to continue +And error details go to stderr +And subsequent hooks do not execute +``` + +#### Scenario: Blocking hook exhausts retry limit + +```gherkin +Given Dev has configured a hook with :retry-on-failure? true and :max-retries 10 +And the hook has failed 10 times +When brepl hook stop runs +Then brepl exits with code 1 +And outputs message that retry limit reached +And Claude is informed but can stop +``` + +#### Scenario: Blocking hook with infinite retries + +```gherkin +Given Dev has configured a hook with :retry-on-failure? true and :max-retries 0 +And the hook fails +When brepl hook stop runs +Then brepl exits with code 2 to force Claude to continue +And this continues indefinitely until hook passes +``` + +#### Scenario: Non-looping hook fails + +```gherkin +Given Dev has configured a hook with :retry-on-failure? false (or omitted) +And the hook fails +When brepl hook stop runs +Then brepl exits with code 1 +And failure is reported to stderr +And Claude is informed but can stop +``` + +Execution behavior: + +- Hooks execute in order defined in config +- Hook failure with `:retry-on-failure? true` exits 2 (Claude retries) until `:max-retries` reached +- Hook failure with `:retry-on-failure? false` exits 1 (Claude informed, can stop) +- Retry count tracked per hook per session in state file + +### Requirement: State Persistence for Retry Tracking + +The system SHALL persist retry counts across hook invocations using a state file in /tmp. + +#### Scenario: Track retry count across invocations + +```gherkin +Given Dev has configured a hook with :retry-on-failure? true +And the hook fails on first invocation +When Claude retries and brepl hook stop runs again +Then brepl reads the previous retry count from state file +And increments the count for this hook +``` + +#### Scenario: Reset retry count on success + +```gherkin +Given a hook has failed 5 times previously +And the hook now succeeds +When brepl hook stop completes +Then the retry count for that hook is reset to 0 +``` + +#### Scenario: Isolate state by session + +```gherkin +Given two Claude sessions are running in the same directory +When each session runs brepl hook stop +Then each session has its own independent retry counts +``` + +State file: + +- Location: `/tmp/brepl-stop-hook-{session_id}.edn` +- Format: EDN map of hook name to retry count +- Cleanup: Removed by SessionEnd hook or on success + +### Requirement: Claude Code Stop Event Integration + +The system SHALL integrate with Claude Code's Stop hook event via `brepl hook stop` command. + +#### Scenario: Receive stop event input + +```gherkin +Given Claude Code fires the Stop event +When brepl hook stop receives JSON input via stdin +Then it parses session_id, transcript_path, and other fields +And proceeds with hook execution +``` + +#### Scenario: All hooks pass + +```gherkin +Given all hooks execute successfully +When brepl hook stop completes +Then brepl exits with code 0 +And Claude can stop +``` + +#### Scenario: Hook fails with loop-on-failure + +```gherkin +Given a hook with :retry-on-failure? true fails +And retry count is below :max-retries +When brepl hook stop completes +Then brepl exits with code 2 +And error details go to stderr +And Claude must continue working +``` + +#### Scenario: Hook fails without loop-on-failure + +```gherkin +Given a hook with :retry-on-failure? false fails +When brepl hook stop completes +Then brepl exits with code 1 +And error details go to stderr +And Claude is informed but can stop +``` + +CLI interface: + +- Command: `brepl hook stop` +- Input: Claude Code Stop event JSON via stdin +- Output: Error messages to stderr +- Exit codes: + - 0 = success, Claude can stop + - 1 = informational error, Claude is informed but can stop + - 2 = blocking error, Claude must continue working + +### Requirement: Install Command Enhancement + +The system SHALL generate a template `.brepl/hooks.edn` file and register Stop hook in Claude settings. + +#### Scenario: Install creates template config + +```gherkin +Given no .brepl/hooks.edn exists +When Dev runs brepl hook install +Then .brepl/hooks.edn is created with commented examples +And the Stop hook is registered in .claude/settings.local.json +``` + +#### Scenario: Install preserves existing brepl config + +```gherkin +Given .brepl/hooks.edn already exists with user configuration +When Dev runs brepl hook install +Then the existing .brepl/hooks.edn is not modified +And the Stop hook is registered in .claude/settings.local.json +``` + +Template content includes: + +- Commented example REPL hook for running tests +- Commented example bash hook for linting +- Documentation of all available fields +- Explanation of blocking vs non-blocking behavior + +### Requirement: Idempotent Claude Settings Merge + +The system SHALL merge brepl hooks with existing Claude settings without replacing non-brepl hooks. + +#### Scenario: Preserve non-brepl hooks during install + +```gherkin +Given .claude/settings.local.json contains a PostToolUse hook for prettier +When Dev runs brepl hook install +Then the prettier hook is preserved +And brepl hooks are added alongside it +``` + +#### Scenario: Update brepl hooks idempotently + +```gherkin +Given .claude/settings.local.json contains older brepl hook configuration +When Dev runs brepl hook install +Then the old brepl hooks are replaced with new configuration +And non-brepl hooks remain unchanged +``` + +#### Scenario: Install is idempotent + +```gherkin +Given Dev has already run brepl hook install +When Dev runs brepl hook install again +Then the settings file contains the same configuration +And no duplicate hooks are created +``` + +Hook identification: + +- Brepl hooks are identified by commands starting with "brepl hook" +- Non-brepl hooks (commands not starting with "brepl hook") are preserved +- Each install replaces all brepl hooks with current configuration + diff --git a/test/installer_test.clj b/test/installer_test.clj new file mode 100644 index 0000000..ee5f42c --- /dev/null +++ b/test/installer_test.clj @@ -0,0 +1,300 @@ +(ns installer-test + "Tests for installer idempotent merge functionality. + + These tests verify that brepl hook install correctly merges hooks + with existing Claude settings without destroying non-brepl hooks." + (:require [clojure.test :refer [deftest is testing]] + [babashka.fs :as fs] + [clojure.java.io :as io] + [cheshire.core :as json])) + +;; Load installer lib +(load-file "lib/installer.clj") +(require '[brepl.lib.installer :as sut]) + +;; ============================================================================= +;; Test Helpers +;; ============================================================================= + +(defn with-temp-dir + "Create a temp directory, execute function, then cleanup" + [f] + (let [dir (fs/create-temp-dir {:prefix "installer-test-"})] + (try + (f (str dir)) + (finally + (fs/delete-tree dir))))) + +(defn create-settings-file + "Create .claude/settings.local.json with given content" + [dir content] + (let [claude-dir (io/file dir ".claude")] + (.mkdirs claude-dir) + (spit (io/file claude-dir "settings.local.json") + (json/generate-string content {:pretty true})))) + +(defn read-settings-file + "Read .claude/settings.local.json as EDN" + [dir] + (let [settings-file (io/file dir ".claude" "settings.local.json")] + (when (.exists settings-file) + (json/parse-string (slurp settings-file) true)))) + +;; ============================================================================= +;; Hook Identification Tests +;; ============================================================================= + +(deftest brepl-hook-identification-test + (testing "brepl-hook? correctly identifies brepl hooks" + (testing "Hook with brepl command is identified as brepl hook" + (is (sut/brepl-hook? {:matcher "Edit" + :hooks [{:type "command" + :command "brepl hook validate"}]}) + "Should identify brepl hook validate")) + + (testing "Hook with brepl hook eval is identified as brepl hook" + (is (sut/brepl-hook? {:matcher "Edit|Write" + :hooks [{:type "command" + :command "brepl hook eval --debug"}]}) + "Should identify brepl hook with flags")) + + (testing "Hook with non-brepl command is not identified as brepl hook" + (is (not (sut/brepl-hook? {:matcher "Write" + :hooks [{:type "command" + :command "prettier --write"}]})) + "Should not identify prettier as brepl hook")) + + (testing "Hook with eslint command is not identified as brepl hook" + (is (not (sut/brepl-hook? {:matcher "*" + :hooks [{:type "command" + :command "eslint --fix"}]})) + "Should not identify eslint as brepl hook")))) + +;; ============================================================================= +;; Merge Hook Event Tests +;; ============================================================================= + +(deftest merge-hook-event-test + (testing "merge-hook-event preserves non-brepl hooks and adds new brepl hooks" + (let [existing [{:matcher "Write" + :hooks [{:type "command" + :command "prettier --write"}]} + {:matcher "Edit" + :hooks [{:type "command" + :command "brepl hook validate"}]}] + new-entries [{:matcher "Edit|Write" + :hooks [{:type "command" + :command "brepl hook validate"}]}] + result (sut/merge-hook-event existing new-entries)] + + (testing "Result contains prettier hook" + (is (some #(= "prettier --write" (get-in % [:hooks 0 :command])) result) + "Should preserve prettier hook")) + + (testing "Result contains new brepl hook" + (is (some #(= "brepl hook validate" (get-in % [:hooks 0 :command])) result) + "Should include new brepl hook")) + + (testing "Old brepl hook is removed" + (is (= 1 (count (filter #(re-find #"brepl hook" (get-in % [:hooks 0 :command] "")) result))) + "Should have exactly one brepl hook after merge"))))) + +;; ============================================================================= +;; Full Merge Tests +;; ============================================================================= + +(deftest merge-hooks-preserves-non-brepl-test + (testing "Scenario: Preserve non-brepl hooks during install" + (testing "Given settings with a prettier PostToolUse hook" + (let [existing-hooks {:PostToolUse [{:matcher "Write" + :hooks [{:type "command" + :command "prettier --write"}]} + {:matcher "Edit" + :hooks [{:type "command" + :command "brepl hook eval"}]}]} + new-hooks {:PreToolUse [{:matcher "Edit|Write" + :hooks [{:type "command" + :command "brepl hook validate"}]}] + :PostToolUse [{:matcher "Edit|Write" + :hooks [{:type "command" + :command "brepl hook eval"}]}] + :Stop [{:matcher "" + :hooks [{:type "command" + :command "brepl hook stop"}]}]} + result (sut/merge-hooks existing-hooks new-hooks)] + + (testing "When brepl hook install runs" + (testing "Then prettier hook is preserved" + (is (some #(= "prettier --write" (get-in % [:hooks 0 :command])) + (:PostToolUse result)) + "Prettier hook should be preserved in PostToolUse")) + + (testing "And brepl hooks are added" + (is (some #(= "brepl hook validate" (get-in % [:hooks 0 :command])) + (:PreToolUse result)) + "PreToolUse should have brepl validate hook") + (is (some #(= "brepl hook eval" (get-in % [:hooks 0 :command])) + (:PostToolUse result)) + "PostToolUse should have brepl eval hook") + (is (some #(= "brepl hook stop" (get-in % [:hooks 0 :command])) + (:Stop result)) + "Stop should have brepl stop hook"))))))) + +(deftest merge-hooks-replaces-old-brepl-test + (testing "Scenario: Replace old brepl hooks" + (testing "Given settings with old brepl hook config" + (let [existing-hooks {:PostToolUse [{:matcher "Edit" + :hooks [{:type "command" + :command "brepl hook eval --old-flag"}]}] + :PreToolUse [{:matcher "Write" + :hooks [{:type "command" + :command "brepl hook validate --old"}]}]} + new-hooks {:PreToolUse [{:matcher "Edit|Write" + :hooks [{:type "command" + :command "brepl hook validate --new"}]}] + :PostToolUse [{:matcher "Edit|Write" + :hooks [{:type "command" + :command "brepl hook eval --new"}]}]} + result (sut/merge-hooks existing-hooks new-hooks)] + + (testing "When brepl hook install runs" + (testing "Then old brepl hooks are replaced" + (is (not (some #(re-find #"--old" (get-in % [:hooks 0 :command] "")) + (:PreToolUse result))) + "Should not have old PreToolUse brepl hook") + (is (not (some #(re-find #"--old" (get-in % [:hooks 0 :command] "")) + (:PostToolUse result))) + "Should not have old PostToolUse brepl hook")) + + (testing "And new brepl hooks are present" + (is (some #(re-find #"--new" (get-in % [:hooks 0 :command] "")) + (:PreToolUse result)) + "Should have new PreToolUse brepl hook") + (is (some #(re-find #"--new" (get-in % [:hooks 0 :command] "")) + (:PostToolUse result)) + "Should have new PostToolUse brepl hook"))))))) + +(deftest merge-hooks-idempotent-test + (testing "Scenario: Install is idempotent" + (testing "Given brepl hooks already installed" + (let [brepl-hooks {:PreToolUse [{:matcher "Edit|Write" + :hooks [{:type "command" + :command "brepl hook validate"}]}] + :PostToolUse [{:matcher "Edit|Write" + :hooks [{:type "command" + :command "brepl hook eval"}]}] + :Stop [{:matcher "" + :hooks [{:type "command" + :command "brepl hook stop"}]}]} + result (sut/merge-hooks brepl-hooks brepl-hooks)] + + (testing "When brepl hook install runs again" + (testing "Then settings are unchanged" + (is (= brepl-hooks result) + "Merged hooks should equal original when merging with same hooks")) + + (testing "And no duplicate hooks are created" + (is (= 1 (count (:PreToolUse result))) + "Should have exactly 1 PreToolUse entry") + (is (= 1 (count (:PostToolUse result))) + "Should have exactly 1 PostToolUse entry") + (is (= 1 (count (:Stop result))) + "Should have exactly 1 Stop entry"))))))) + +;; ============================================================================= +;; Mixed Non-Brepl Hooks Tests +;; ============================================================================= + +(deftest preserve-multiple-non-brepl-hooks-test + (testing "Preserve multiple non-brepl hooks across different events" + (let [existing-hooks {:PreToolUse [{:matcher "*" + :hooks [{:type "command" + :command "custom-validator"}]}] + :PostToolUse [{:matcher "Write" + :hooks [{:type "command" + :command "prettier --write"}]} + {:matcher "*.js" + :hooks [{:type "command" + :command "eslint --fix"}]} + {:matcher "Edit" + :hooks [{:type "command" + :command "brepl hook eval"}]}] + :Stop [{:matcher "" + :hooks [{:type "command" + :command "cleanup-script"}]}]} + new-hooks (sut/brepl-hook-config {}) + result (sut/merge-hooks existing-hooks new-hooks)] + + (testing "All non-brepl PreToolUse hooks preserved" + (is (some #(= "custom-validator" (get-in % [:hooks 0 :command])) + (:PreToolUse result)) + "Custom validator should be preserved")) + + (testing "All non-brepl PostToolUse hooks preserved" + (is (some #(= "prettier --write" (get-in % [:hooks 0 :command])) + (:PostToolUse result)) + "Prettier should be preserved") + (is (some #(= "eslint --fix" (get-in % [:hooks 0 :command])) + (:PostToolUse result)) + "ESLint should be preserved")) + + (testing "All non-brepl Stop hooks preserved" + (is (some #(= "cleanup-script" (get-in % [:hooks 0 :command])) + (:Stop result)) + "Cleanup script should be preserved")) + + (testing "Brepl hooks are also present" + (is (some #(re-find #"brepl hook" (get-in % [:hooks 0 :command] "")) + (:PreToolUse result)) + "Brepl PreToolUse hook should be present") + (is (some #(re-find #"brepl hook" (get-in % [:hooks 0 :command] "")) + (:PostToolUse result)) + "Brepl PostToolUse hook should be present") + (is (some #(re-find #"brepl hook" (get-in % [:hooks 0 :command] "")) + (:Stop result)) + "Brepl Stop hook should be present"))))) + +;; ============================================================================= +;; brepl-hook-config Tests +;; ============================================================================= + +(deftest brepl-hook-config-includes-stop-test + (testing "brepl-hook-config generates Stop hook configuration" + (let [config (sut/brepl-hook-config {})] + (testing "Stop hook is present" + (is (contains? config :Stop) + "Should have :Stop key")) + + (testing "Stop hook has correct command" + (is (= "brepl hook stop" (get-in config [:Stop 0 :hooks 0 :command])) + "Stop hook should have 'brepl hook stop' command")) + + (testing "Stop hook has empty matcher" + (is (= "" (get-in config [:Stop 0 :matcher])) + "Stop hook should have empty matcher"))))) + +(deftest brepl-hook-config-includes-session-end-test + (testing "brepl-hook-config generates SessionEnd hook configuration" + (let [config (sut/brepl-hook-config {})] + (testing "SessionEnd hook is present" + (is (contains? config :SessionEnd) + "Should have :SessionEnd key")) + + (testing "SessionEnd hook has correct command" + (is (= "brepl hook session-end" (get-in config [:SessionEnd 0 :hooks 0 :command])) + "SessionEnd hook should have 'brepl hook session-end' command"))))) + +(deftest brepl-hook-config-debug-flag-test + (testing "brepl-hook-config adds debug flag when requested" + (let [config (sut/brepl-hook-config {:debug true})] + (testing "PreToolUse has debug flag" + (is (re-find #"--debug" (get-in config [:PreToolUse 0 :hooks 0 :command])) + "PreToolUse should have --debug flag")) + + (testing "PostToolUse has debug flag" + (is (re-find #"--debug" (get-in config [:PostToolUse 0 :hooks 0 :command])) + "PostToolUse should have --debug flag")) + + (testing "Stop has debug flag" + (is (re-find #"--debug" (get-in config [:Stop 0 :hooks 0 :command])) + "Stop should have --debug flag"))))) diff --git a/test/stop_hooks_test.clj b/test/stop_hooks_test.clj new file mode 100644 index 0000000..39d5f46 --- /dev/null +++ b/test/stop_hooks_test.clj @@ -0,0 +1,519 @@ +(ns stop-hooks-test + "Tests for stop hooks functionality. + + These tests are written in TDD red-phase style - they define expected behavior + based on the spec before the implementation exists. They will fail initially + and pass once lib/stop_hooks.clj is implemented." + (:require [clojure.test :refer [deftest is testing]] + [babashka.nrepl.server :as srv] + [babashka.process :refer [shell]] + [babashka.fs :as fs] + [clojure.java.io :as io] + [cheshire.core :as json])) + +;; ============================================================================= +;; Test Helpers +;; ============================================================================= + +;; Get absolute path to brepl script (works from any cwd) +(def brepl-path + (-> (System/getProperty "user.dir") + (io/file "brepl") + .getAbsolutePath)) + +(defn find-free-port + "Find an available port by creating and immediately closing a ServerSocket" + [] + (let [socket (java.net.ServerSocket. 0) + port (.getLocalPort socket)] + (.close socket) + port)) + +(defn with-nrepl-server + "Start nREPL server, execute function with port, then stop server" + [f] + (let [port (find-free-port) + server (srv/start-server! {:port port :quiet true})] + (try + (Thread/sleep 100) + (f port) + (finally + (srv/stop-server! server))))) + +(defn with-temp-dir + "Create a temp directory, execute function, then cleanup" + [f] + (let [dir (fs/create-temp-dir {:prefix "brepl-test-"})] + (try + (f (str dir)) + (finally + (fs/delete-tree dir))))) + +(defn create-hooks-config + "Create .brepl/hooks.edn with given content" + [dir content] + (let [brepl-dir (io/file dir ".brepl")] + (.mkdirs brepl-dir) + (spit (io/file brepl-dir "hooks.edn") content))) + +(defn create-nrepl-port-file + "Create .nrepl-port file in directory" + [dir port] + (spit (io/file dir ".nrepl-port") (str port))) + +(defn run-stop-hook + "Run brepl hook stop with given stdin input and options" + ([dir] (run-stop-hook dir {})) + ([dir {:keys [session-id stdin-data env timeout] + :or {session-id "test-session-123" + timeout 10000}}] + (let [stdin-json (json/generate-string + (merge {:hook_event_name "Stop" + :session_id session-id} + stdin-data)) + result (shell {:out :string + :err :string + :continue true + :dir dir + :in stdin-json + :timeout timeout + :extra-env (or env {})} + brepl-path "hook" "stop")] + (assoc result + :stdout-parsed (when (seq (:out result)) + (try (json/parse-string (:out result) true) + (catch Exception _ nil))))))) + +(defn cleanup-state-files + "Remove any state files for a session" + [session-id] + (let [state-file (io/file (str "/tmp/brepl-stop-hook-" session-id ".edn"))] + (when (.exists state-file) + (.delete state-file)))) + +;; ============================================================================= +;; Schema Validation Tests +;; ============================================================================= + +(deftest valid-repl-hook-schema-test + (testing "Scenario: Valid REPL hook" + (testing "Given a hook map with :type :repl, :name 'tests', and :code '(run-tests)'" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :repl + :name \"tests\" + :code \"(run-tests)\"}]}") + (testing "When the schema is validated" + (let [result (run-stop-hook dir)] + (testing "Then the hook is accepted (no validation error)" + ;; Without nREPL, optional hook should be skipped silently + ;; The key is no schema validation error + (is (not (re-find #"(?i)schema|validation.*failed|invalid.*hook" + (str (:err result)))) + "Should not report schema validation errors"))))))))) + +(deftest valid-bash-hook-schema-test + (testing "Scenario: Valid bash hook" + (testing "Given a hook map with :type :bash, :name 'lint', and :command 'clj-kondo --lint src'" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :bash + :name \"lint\" + :command \"echo hello\"}]}") + (testing "When the schema is validated" + (let [result (run-stop-hook dir)] + (testing "Then the hook is accepted" + ;; Should execute without schema errors + (is (= 0 (:exit result)) + "Should exit 0 for successful bash hook"))))))))) + +(deftest missing-required-field-schema-test + (testing "Scenario: Missing required field" + (testing "Given a REPL hook missing the :code field" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :repl}]}") + (testing "When the schema is validated" + (let [result (run-stop-hook dir)] + (testing "Then validation fails" + (is (= 1 (:exit result)) + "Should exit 1 for validation error") + (is (re-find #"(?i)code|invalid" + (str (:err result))) + "Should mention missing :code field"))))))))) + +(deftest invalid-field-type-schema-test + (testing "Scenario: Invalid field type" + (testing "Given a hook map with :timeout \"sixty\" (string instead of integer)" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :repl + :name \"test\" + :code \"(+ 1 2)\" + :timeout \"sixty\"}]}") + (testing "When the schema is validated" + (let [result (run-stop-hook dir)] + (testing "Then validation fails with explanation of type mismatch" + (is (= 1 (:exit result)) + "Should exit 1 for validation error") + (is (re-find #"(?i)timeout|integer|type" + (str (:err result))) + "Should mention type mismatch for :timeout"))))))))) + +;; ============================================================================= +;; REPL Hook Execution Tests +;; ============================================================================= + +(deftest successful-repl-hook-execution-test + (testing "Scenario: Successful REPL hook execution" + (with-nrepl-server + (fn [port] + (testing "Given a REPL hook with :code '(+ 1 2)' and an nREPL server is running" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + (str "{:stop [{:type :repl + :name \"simple-eval\" + :code \"(+ 1 2)\"}]}")) + (create-nrepl-port-file dir port) + (testing "When the stop hook executes" + (let [result (run-stop-hook dir)] + (testing "Then the hook is marked as successful" + (is (= 0 (:exit result)) + "Should exit 0 for successful REPL evaluation"))))))))))) + +(deftest repl-hook-with-evaluation-error-test + (testing "Scenario: REPL hook with evaluation error" + (with-nrepl-server + (fn [port] + (testing "Given a REPL hook with :code '(/ 1 0)' and an nREPL server is running" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + (str "{:stop [{:type :repl + :name \"divide-by-zero\" + :code \"(/ 1 0)\"}]}")) + (create-nrepl-port-file dir port) + (testing "When the stop hook executes" + (let [result (run-stop-hook dir)] + (testing "Then the hook is marked as failed" + (is (= 1 (:exit result)) + "Should exit 1 for evaluation error")) + (testing "And the error message is captured" + (is (re-find #"(?i)divide|arithmetic|zero" + (str (:err result))) + "Should capture division by zero error"))))))))))) + +(deftest no-nrepl-with-required-hook-test + (testing "Scenario: No nREPL available with required hook" + (testing "Given a REPL hook with :required? true and no nREPL server is running" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :repl + :name \"required-tests\" + :code \"(run-tests)\" + :required? true}]}") + ;; No .nrepl-port file, no server + (testing "When the stop hook executes first time" + (let [result (run-stop-hook dir)] + (testing "Then it blocks (exit 2) to force Claude to react" + (is (= 2 (:exit result)) + "Should exit 2 on first attempt to force Claude to inform user")) + (testing "And outputs message asking Claude to inform user" + (is (re-find #"(?i)nrepl|inform.*user|not.*running" + (str (:err result))) + "Should mention nREPL unavailable for required hook"))))))))) + +(deftest no-nrepl-with-optional-hook-test + (testing "Scenario: No nREPL available with optional hook" + (testing "Given a REPL hook with :required? false and no nREPL server is running" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :repl + :name \"optional-tests\" + :code \"(run-tests)\" + :required? false}]}") + ;; No .nrepl-port file, no server + (testing "When the stop hook executes" + (let [result (run-stop-hook dir)] + (testing "Then the hook is skipped silently" + (is (= 0 (:exit result)) + "Should exit 0 when optional hook skipped")) + (testing "And execution continues (no error about missing nREPL)" + (is (not (re-find #"(?i)error|fail" + (str (:err result)))) + "Should not report errors for skipped optional hook"))))))))) + +;; ============================================================================= +;; Bash Hook Execution Tests +;; ============================================================================= + +(deftest successful-bash-hook-test + (testing "Scenario: Successful bash hook execution" + (testing "Given a bash hook with :command 'echo hello'" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :bash + :name \"echo-test\" + :command \"echo hello\"}]}") + (testing "When the stop hook executes" + (let [result (run-stop-hook dir)] + (testing "Then exit code 0 marks success" + (is (= 0 (:exit result)) + "Should exit 0 for successful bash command"))))))))) + +(deftest bash-hook-with-non-zero-exit-test + (testing "Scenario: Bash hook with non-zero exit" + (testing "Given a bash hook with :command 'exit 1'" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :bash + :name \"failing-command\" + :command \"exit 1\"}]}") + (testing "When the stop hook executes" + (let [result (run-stop-hook dir)] + (testing "Then the hook is marked as failed" + (is (= 1 (:exit result)) + "Should exit 1 for failed bash command"))))))))) + +(deftest bash-hook-with-cwd-test + (testing "Scenario: Bash hook with custom working directory" + (testing "Given a bash hook with :cwd pointing to /tmp" + (with-temp-dir + (fn [dir] + ;; Create a subdirectory and marker file + (let [sub-dir (io/file dir "subdir")] + (.mkdirs sub-dir) + (spit (io/file sub-dir "marker.txt") "found")) + (create-hooks-config dir + "{:stop [{:type :bash + :name \"cwd-test\" + :command \"cat marker.txt\" + :cwd \"subdir\"}]}") + (testing "When the stop hook executes" + (let [result (run-stop-hook dir)] + (testing "Then command runs in the specified directory" + (is (= 0 (:exit result)) + "Should exit 0 when command finds file in cwd"))))))))) + +(deftest bash-hook-with-env-test + (testing "Scenario: Bash hook with environment variables" + (testing "Given a bash hook with :env {\"TEST_VAR\" \"test_value\"}" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :bash + :name \"env-test\" + :command \"test \\\"$TEST_VAR\\\" = \\\"test_value\\\"\" + :env {\"TEST_VAR\" \"test_value\"}}]}") + (testing "When the stop hook executes" + (let [result (run-stop-hook dir)] + (testing "Then command has TEST_VAR set" + (is (= 0 (:exit result)) + "Should exit 0 when env var is correctly set"))))))))) + +;; ============================================================================= +;; State Persistence Tests +;; ============================================================================= + +(deftest track-retry-count-test + (testing "Scenario: Track retry count across invocations" + (testing "Given a failing hook with :required? true" + (with-temp-dir + (fn [dir] + (let [session-id (str "retry-test-" (System/currentTimeMillis))] + (try + (create-hooks-config dir + "{:stop [{:type :bash + :name \"always-fails\" + :command \"exit 1\" + :required? true + :max-retries 5}]}") + (testing "When invoked twice" + (let [result1 (run-stop-hook dir {:session-id session-id}) + result2 (run-stop-hook dir {:session-id session-id})] + (testing "Then exit code is 2 (retry) both times" + (is (= 2 (:exit result1)) + "First invocation should return exit 2") + (is (= 2 (:exit result2)) + "Second invocation should return exit 2")))) + (finally + (cleanup-state-files session-id))))))))) + +(deftest reset-retry-count-on-success-test + (testing "Scenario: Reset retry count on success" + (with-temp-dir + (fn [dir] + (let [session-id (str "reset-test-" (System/currentTimeMillis)) + counter-file (io/file dir "counter")] + (try + ;; Use a counter file to make the hook fail first, then succeed + (spit counter-file "0") + (create-hooks-config dir + (str "{:stop [{:type :bash + :name \"eventually-passes\" + :command \"count=$(cat counter); if [ $count -lt 2 ]; then echo $((count + 1)) > counter; exit 1; else exit 0; fi\" + :required? true}]}")) + ;; Run hook 3 times - should fail, fail, succeed + (testing "Given a hook that failed multiple times then succeeds" + (run-stop-hook dir {:session-id session-id}) + (run-stop-hook dir {:session-id session-id}) + (let [result3 (run-stop-hook dir {:session-id session-id})] + (testing "Then retry count resets to 0 on success" + (is (= 0 (:exit result3)) + "Should exit 0 when hook finally succeeds")))) + (finally + (cleanup-state-files session-id)))))))) + +;; ============================================================================= +;; Orchestration Tests +;; ============================================================================= + +(deftest all-hooks-pass-test + (testing "Scenario: All hooks pass" + (testing "Given multiple passing hooks" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :bash + :name \"first\" + :command \"echo first\"} + {:type :bash + :name \"second\" + :command \"echo second\"} + {:type :bash + :name \"third\" + :command \"echo third\"}]}") + (testing "When run-stop-hooks executes" + (let [result (run-stop-hook dir)] + (testing "Then exit code is 0" + (is (= 0 (:exit result)) + "Should exit 0 when all hooks pass"))))))))) + +(deftest blocking-hook-fails-retry-test + (testing "Scenario: Blocking hook fails (retry)" + (testing "Given hook with :required? true that fails" + (with-temp-dir + (fn [dir] + (let [session-id (str "blocking-retry-" (System/currentTimeMillis))] + (try + (create-hooks-config dir + "{:stop [{:type :bash + :name \"blocking-fail\" + :command \"exit 1\" + :required? true + :max-retries 5}]}") + (testing "And retry count < max-retries" + (testing "When run-stop-hooks executes" + (let [result (run-stop-hook dir {:session-id session-id})] + (testing "Then exit code is 2 (Claude must continue)" + (is (= 2 (:exit result)) + "Should exit 2 to force Claude to continue"))))) + (finally + (cleanup-state-files session-id))))))))) + +(deftest blocking-hook-exhausts-retries-test + (testing "Scenario: Blocking hook exhausts retry limit" + (testing "Given hook with :required? true, :max-retries 3" + (with-temp-dir + (fn [dir] + (let [session-id (str "exhaust-retries-" (System/currentTimeMillis))] + (try + (create-hooks-config dir + "{:stop [{:type :bash + :name \"exhaust-retries\" + :command \"exit 1\" + :required? true + :max-retries 3}]}") + (testing "And hook has failed 3 times" + ;; Run 3 times to exhaust retries + (run-stop-hook dir {:session-id session-id}) + (run-stop-hook dir {:session-id session-id}) + (run-stop-hook dir {:session-id session-id}) + (testing "When run-stop-hooks executes again" + (let [result (run-stop-hook dir {:session-id session-id})] + (testing "Then exit code is 1 (retry limit reached, Claude can stop)" + (is (= 1 (:exit result)) + "Should exit 1 when retries exhausted")) + (testing "And outputs message about retry limit" + (is (re-find #"(?i)retry|limit|exhaust|max" + (str (:err result))) + "Should mention retry limit reached"))))) + (finally + (cleanup-state-files session-id))))))))) + +(deftest non-blocking-hook-fails-test + (testing "Scenario: Non-blocking hook fails" + (testing "Given hook with :required? false that fails" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :bash + :name \"non-blocking-fail\" + :command \"exit 1\" + :required? false}]}") + (testing "When run-stop-hooks executes" + (let [result (run-stop-hook dir)] + (testing "Then exit code is 1 (Claude informed, can stop)" + (is (= 1 (:exit result)) + "Should exit 1 for non-blocking failure"))))))))) + +;; ============================================================================= +;; Configuration Loading Tests +;; ============================================================================= + +(deftest no-config-file-test + (testing "Scenario: No configuration file exists" + (testing "Given no .brepl/hooks.edn file exists" + (with-temp-dir + (fn [dir] + ;; Don't create hooks.edn + (testing "When brepl hook stop executes" + (let [result (run-stop-hook dir)] + (testing "Then brepl returns success with no hooks executed" + (is (= 0 (:exit result)) + "Should exit 0 when no config exists"))))))))) + +(deftest invalid-config-format-test + (testing "Scenario: Invalid configuration format" + (testing "Given .brepl/hooks.edn contains malformed EDN" + (with-temp-dir + (fn [dir] + (create-hooks-config dir "{:stop [invalid edn here") + (testing "When brepl hook stop executes" + (let [result (run-stop-hook dir)] + (testing "Then brepl exits with code 1" + (is (= 1 (:exit result)) + "Should exit 1 for malformed config")) + (testing "And outputs error to stderr" + (is (re-find #"(?i)error|invalid|parse|edn" + (str (:err result))) + "Should mention parse error"))))))))) + +;; ============================================================================= +;; Claude Code Integration Tests +;; ============================================================================= + +(deftest receive-stop-event-input-test + (testing "Scenario: Receive stop event input from Claude Code" + (testing "Given Claude Code fires the Stop event" + (with-temp-dir + (fn [dir] + (create-hooks-config dir + "{:stop [{:type :bash + :name \"simple\" + :command \"echo done\"}]}") + (testing "When brepl hook stop receives JSON input via stdin" + (let [result (run-stop-hook dir {:stdin-data {:transcript_path "/tmp/transcript.txt" + :some_other_field "value"}})] + (testing "Then it parses session_id and proceeds with hook execution" + (is (= 0 (:exit result)) + "Should successfully execute hooks with Claude Code input")))))))))