Skip to content

Instantly share code, notes, and snippets.

@cdolan
Created April 3, 2026 14:59
Show Gist options
  • Select an option

  • Save cdolan/3070bc11e92a754e5188e40f504af314 to your computer and use it in GitHub Desktop.

Select an option

Save cdolan/3070bc11e92a754e5188e40f504af314 to your computer and use it in GitHub Desktop.
slop
#!/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