Created
April 3, 2026 14:59
-
-
Save cdolan/3070bc11e92a754e5188e40f504af314 to your computer and use it in GitHub Desktop.
slop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # | |
| # slop — run the same prompt through Claude Code multiple times in parallel, | |
| # then have Claude analyze and compare all the results. | |
| # | |
| # Requires: Claude Code CLI (`claude`) in your PATH | |
| # | |
| # Usage: | |
| # slop [options] [prompt] | |
| # | |
| # Examples: | |
| # slop "Explain the Unix philosophy" | |
| # slop -n 5 "Write a function to parse CSV" | |
| # slop -f prompt.txt --output-dir my_results/ | |
| # slop -n 3 --analysis-prompt "Rank by clarity" "What is a monad?" | |
| # | |
| # Options: | |
| # -n, --sessions N Number of parallel sessions (default: 3) | |
| # -f, --file PATH Read prompt from a file instead of argv | |
| # --output-dir DIR Where to write results (default: results/) | |
| # --analysis-prompt TEXT Custom prompt for the analysis phase | |
| # | |
| require "optparse" | |
| require "fileutils" | |
| require "open3" | |
| require "json" | |
| require "ostruct" | |
| # Recursively converts a nested JSON hash into an object with dot-accessor | |
| # methods. e.g. ClaudeResult.new({"usage" => {"output_tokens" => 12}}) | |
| # becomes result.usage.output_tokens — all the way down. | |
| class ClaudeResult < OpenStruct | |
| def self.parse(json) | |
| new(JSON.parse(json)) | |
| end | |
| def initialize(hash) | |
| fields = hash.to_h do |key, value| | |
| [key.to_sym, value.is_a?(Hash) ? ClaudeResult.new(value) : value] | |
| end | |
| super(fields) | |
| end | |
| end | |
| # A running claude process with its IO handles and wait thread. | |
| # Knows its session number and how to read/wait on itself. | |
| class Worker | |
| attr_reader :idx, :stdout, :stderr, :thread | |
| attr_accessor :status, :elapsed | |
| def initialize(idx, stdin, stdout, stderr, thread) | |
| @idx = idx | |
| @stdout = stdout | |
| @stderr = stderr | |
| @thread = thread | |
| @status = :running | |
| @elapsed = nil | |
| stdin.close | |
| end | |
| def pid = thread.pid | |
| def wait | |
| out = stdout.read | |
| err = stderr.read | |
| stdout.close | |
| stderr.close | |
| status = thread.value | |
| { output: out, stderr: err, status: status } | |
| end | |
| end | |
| def claude_cmd(effort: nil) | |
| cmd = ["claude", "-p", "--output-format", "json"] | |
| cmd.push("--effort", effort) if effort | |
| cmd | |
| end | |
| DEFAULT_ANALYSIS_PROMPT = <<~PROMPT.freeze | |
| You are reviewing multiple independent responses to the same prompt. | |
| Compare and contrast the approaches. Identify the strongest ideas, | |
| common themes, contradictions, and any errors. Provide a unified | |
| recommendation or synthesis. | |
| PROMPT | |
| def parse_options(argv) | |
| # Defaults | |
| opts = { sessions: 3, output_dir: "results" } | |
| parser = OptionParser.new do |o| | |
| o.banner = "Usage: slop [options] [prompt]" | |
| o.on("-n", "--sessions N", Integer, "Number of parallel sessions (default: 3)") { |n| opts[:sessions] = n } | |
| o.on("-f", "--file PATH", "Read prompt from file") { |f| opts[:file] = f } | |
| o.on("--output-dir DIR", "Output directory (default: results/)") { |d| opts[:output_dir] = d } | |
| o.on("-e", "--effort LEVEL", "Effort level: low, medium, high, max") { |e| opts[:effort] = e } | |
| o.on("--analysis-prompt TEXT", "Custom analysis prompt") { |t| opts[:analysis_prompt] = t } | |
| end | |
| parser.parse!(argv) | |
| opts[:prompt] = if opts[:file] | |
| abort "Cannot specify both --file and a prompt argument" unless argv.empty? | |
| File.read(opts[:file]) | |
| else | |
| abort parser.to_s if argv.empty? | |
| argv.join(" ") | |
| end | |
| opts[:analysis_prompt] ||= DEFAULT_ANALYSIS_PROMPT | |
| opts | |
| end | |
| def run_sessions(prompt, count, output_dir, effort: nil) | |
| FileUtils.mkdir_p(output_dir) | |
| workers = count.times.map do |i| | |
| Worker.new(i, *Open3.popen3(*claude_cmd(effort: effort), "--", prompt)) | |
| end | |
| %w[INT TERM].each do |sig| | |
| trap(sig) do | |
| workers.each { |w| Process.kill(sig, w.pid) rescue nil } | |
| exit 1 | |
| end | |
| end | |
| # Collect raw results in threads, print stats after ticker is cleared | |
| results = Array.new(count) | |
| start = Process.clock_gettime(Process::CLOCK_MONOTONIC) | |
| threads = workers.map do |w| | |
| Thread.new do | |
| results[w.idx] = w.wait | |
| w.elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start | |
| w.status = results[w.idx][:status].success? ? :done : :failed | |
| end | |
| end | |
| # Live status line, updated twice a second | |
| ticking = true | |
| ticker = Thread.new do | |
| while ticking | |
| now = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start | |
| line = workers.map { |w| | |
| secs = w.elapsed || now | |
| "#{w.idx + 1}:#{w.status} #{format("%.1fs", secs)}" | |
| }.join(" ") | |
| $stderr.print format("\r [%s]", line) | |
| sleep 0.5 | |
| end | |
| end | |
| threads.each(&:join) | |
| ticking = false | |
| ticker.join | |
| $stderr.print "\r#{" " * 60}\r" | |
| # Now print final stats for each session | |
| paths = [] | |
| stats = [] | |
| failures = 0 | |
| results.each_with_index do |r, idx| | |
| i = idx + 1 | |
| path = File.join(output_dir, "session_#{i}.md") | |
| unless r[:status].success? | |
| failures += 1 | |
| warn " session #{i} failed (exit #{r[:status].exitstatus}, pid #{r[:status].pid})" | |
| warn " #{r[:stderr].lines.first&.chomp}" unless r[:stderr].empty? | |
| next | |
| end | |
| data = ClaudeResult.parse(r[:output]) | |
| File.write(path, data.result) | |
| paths << path | |
| stats << data | |
| input = data.usage.input_tokens.to_i + | |
| data.usage.cache_read_input_tokens.to_i + | |
| data.usage.cache_creation_input_tokens.to_i | |
| warn format(" session %d — %d in / %d out, $%.4f, %.1fs", | |
| i, input, data.usage.output_tokens, data.total_cost_usd, data.duration_ms / 1000.0) | |
| end | |
| abort "All sessions failed" if paths.empty? | |
| warn " #{failures} session(s) failed" if failures > 0 | |
| [paths, stats] | |
| end | |
| def run_analysis(paths, analysis_prompt, output_dir, effort: nil) | |
| combined = paths.map { |p| "=== #{File.basename(p)} ===\n#{File.read(p)}" }.join("\n\n") | |
| full_prompt = "#{analysis_prompt}\n\nHere are the responses:\n\n#{combined}" | |
| stdout, stderr, status = Open3.capture3(*claude_cmd(effort: effort), "--", full_prompt) | |
| unless status.success? | |
| abort "Analysis failed (exit #{status.exitstatus}): #{stderr.lines.first&.chomp}" | |
| end | |
| data = ClaudeResult.parse(stdout) | |
| path = File.join(output_dir, "analysis.md") | |
| File.write(path, data.result) | |
| [path, data] | |
| end | |
| def print_stats(label, all_stats) | |
| total_in = all_stats.sum { |s| s.usage.input_tokens.to_i + s.usage.cache_read_input_tokens.to_i + s.usage.cache_creation_input_tokens.to_i } | |
| total_out = all_stats.sum { |s| s.usage.output_tokens.to_i } | |
| total_cost = all_stats.sum(&:total_cost_usd) | |
| wall_ms = all_stats.map(&:duration_ms).max | |
| cpu_ms = all_stats.sum(&:duration_ms) | |
| warn format(" %s: %d in / %d out, $%.4f, %.1fs wall (%.1fs total across sessions)", | |
| label, total_in, total_out, total_cost, wall_ms / 1000.0, cpu_ms / 1000.0) | |
| end | |
| def main | |
| opts = parse_options(ARGV) | |
| warn "Running #{opts[:sessions]} sessions..." | |
| paths, session_stats = run_sessions(opts[:prompt], opts[:sessions], opts[:output_dir], effort: opts[:effort]) | |
| paths.each { |p| warn " wrote #{p}" } | |
| warn "Running analysis..." | |
| analysis_path, analysis_data = run_analysis(paths, opts[:analysis_prompt], opts[:output_dir], effort: opts[:effort]) | |
| warn " wrote #{analysis_path}" | |
| all_stats = session_stats + [analysis_data] | |
| print_stats("total", all_stats) | |
| end | |
| main |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment