pickleton-petri-dish 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5b95ca9efff8ce626395557d74a6ab09967d51556e29709cb85a7ba53f382192
4
+ data.tar.gz: f08731d400742608c739c3437e5273352365d45ccc8cb3f160c570c76aa13e4a
5
+ SHA512:
6
+ metadata.gz: 719be058a348d22e7ff347287c68f29ca31fd403d375e5ab8d0cb03da584b5cf71c02241592e1e09eda51f4067fe0124cbe854f1d3066a4985ff1a34af36bb7e
7
+ data.tar.gz: d4e8a154850e6db5d3584e632704a04adcf8ae493ae94ceabfde8a51041a2adc5fe45e310a737f428786e8ef83116916f271d9ece92da087f7d4fee03980c787
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,36 @@
1
+ # Contributing to petri-dish
2
+
3
+ Thanks for considering a contribution.
4
+
5
+ ## Adding an example culture
6
+
7
+ Each culture lives under `examples/<name>/` and contains:
8
+
9
+ - `config.yml`: environment, plugins, settings, runtime config, prompt mode.
10
+ - `prompt.md`: what Claude should do.
11
+
12
+ See existing examples for the shape, and read [`claude-plugin/skills/petri-dish/references/authoring-cultures.md`](claude-plugin/skills/petri-dish/references/authoring-cultures.md) for the conventions behind those shapes (prompt anatomy, baseline cells, preamble selection, multi-run discipline, gotchas). Culture names follow `category-NN[variant]-short-description`, e.g. `sandbox-07b-escape-hatch-disabled`.
13
+
14
+ For multi-part cultures sharing a prompt, symlink the shared prompt:
15
+
16
+ ```
17
+ examples/sandbox-07b-escape-hatch-disabled/prompt.md -> ../sandbox-07-escape-hatch/prompt.md
18
+ ```
19
+
20
+ ## Running an example locally
21
+
22
+ ```
23
+ petri-dish setup --cultures-dir examples sandbox-01-baseline
24
+ petri-dish run --cultures-dir examples sandbox-01-baseline
25
+ petri-dish results --cultures-dir examples sandbox-01-baseline
26
+ ```
27
+
28
+ ## Code style
29
+
30
+ - `frozen_string_literal: true` at the top of every Ruby file.
31
+ - No em-dashes in user-facing strings (use parens, colons, or split sentences).
32
+ - Keep modules small and focused.
33
+
34
+ ## Submitting changes
35
+
36
+ Open a PR with a clear description of the change and which examples were verified.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josh Nichols
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # petri-dish
2
+
3
+ A petri-dish for agentic coding experiments. Keep your cultures in your lab; load them into a dish to see how they grow.
4
+
5
+ ## Why
6
+
7
+ Your `~/.claude/` is production. There's no staging copy of it, no rollback if a hook misbehaves or a plugin clobbers your settings. [I've written about this.](https://pickles.dev/dot-claude-is-production/) `CLAUDE_CONFIG_DIR` gets you a sandbox; petri-dish gets you reproducible experiments inside one.
8
+
9
+ A **culture** is one experiment: a `config.yml` + `prompt.md` pair. A **petri-dish** is what runs it. Each dish is isolated via [cenv](https://github.com/technicalpickles/cenv) so the experiment never touches your real config, and hooks injected into the dish observe the run from inside without disturbing it.
10
+
11
+ ## What it does
12
+
13
+ `petri-dish run <culture>` boots an isolated cenv environment, injects PreToolUse/PostToolUse/PermissionRequest hooks, runs Claude Code against your prompt, and correlates the event log into a structured results file.
14
+
15
+ ## Requirements
16
+
17
+ - Ruby >= 3.2
18
+ - [cenv](https://github.com/technicalpickles/cenv) for environment isolation
19
+ - tmux
20
+ - The Claude Code CLI (`claude`)
21
+
22
+ ## Install
23
+
24
+ ```
25
+ gem install pickleton-petri-dish
26
+ ```
27
+
28
+ That puts `petri-dish` on your PATH. (The gem name is prefixed because `petri_dish` was already taken on rubygems; the CLI stays `petri-dish`.)
29
+
30
+ To build from source instead:
31
+
32
+ ```
33
+ git clone https://github.com/technicalpickles/petri-dish.git
34
+ cd petri-dish
35
+ gem build petri-dish.gemspec
36
+ gem install pickleton-petri-dish-0.1.0.gem
37
+ ```
38
+
39
+ ## Quick start
40
+
41
+ ```
42
+ mkdir my-lab && cd my-lab
43
+ mkdir -p cultures/my-first-culture
44
+ # write cultures/my-first-culture/config.yml and prompt.md
45
+ petri-dish setup my-first-culture
46
+ petri-dish run my-first-culture
47
+ petri-dish results my-first-culture
48
+ ```
49
+
50
+ By default, petri-dish looks for cultures in `./cultures/` relative to your current directory. Override with `--cultures-dir <path>` or `PETRIDISH_CULTURES_DIR`.
51
+
52
+ To explore the bundled examples, from the cloned petri-dish repo:
53
+
54
+ ```
55
+ petri-dish run --cultures-dir examples sandbox-01-baseline
56
+ ```
57
+
58
+ ## How it works
59
+
60
+ 1. **Environment.** Each culture gets its own cenv environment with specific settings (sandbox config, permissions, plugins). Nothing touches your `~/.claude/`.
61
+ 2. **Hooks.** PreToolUse, PostToolUse, and PermissionRequest hooks are injected into the dish to log events and auto-handle permission prompts.
62
+ 3. **Prompt.** A markdown prompt tells Claude what commands to run and what to observe.
63
+ 4. **Results.** Hook event logs are correlated into structured results (prompted vs silent, timing, outcomes).
64
+
65
+ ## CLI reference
66
+
67
+ ```
68
+ Usage: petri-dish <command> [options]
69
+
70
+ Commands:
71
+ run <culture> [--deny] [--debug] [--keep] [--cultures-dir DIR]
72
+ list List available cultures
73
+ results [culture] Show past results
74
+ setup [culture] Create environments
75
+ setup --clean [culture] Tear down environments
76
+ help Show this help
77
+ ```
78
+
79
+ ## Adding a culture
80
+
81
+ Each culture directory needs two files:
82
+
83
+ ```
84
+ cultures/my-culture/
85
+ config.yml
86
+ prompt.md
87
+ ```
88
+
89
+ See `examples/` for working configs. The schema:
90
+
91
+ ```yaml
92
+ name: my-culture
93
+ description: "One-line description"
94
+
95
+ environment:
96
+ name: test-my-culture
97
+ plugins: []
98
+ settings:
99
+ sandbox:
100
+ enabled: true
101
+ permissions:
102
+ allow:
103
+ - Read
104
+ - Write
105
+
106
+ runtime:
107
+ work_dir: /tmp/sandbox-test
108
+ preamble: lib/preambles/sandbox.md # optional, resolves to a bundled or local preamble
109
+ inject_results_file: true
110
+ timeout: 300
111
+
112
+ prompt_mode: accept # or "deny"
113
+ ```
114
+
115
+ For a longer reference, including prompt-shape conventions, cell discipline (baseline cells, multi-run averaging, A/B variants), preamble selection, and the brittle bits to watch for, see [`claude-plugin/skills/petri-dish/references/authoring-cultures.md`](claude-plugin/skills/petri-dish/references/authoring-cultures.md).
116
+
117
+ If you use Claude Code, the [companion plugin](claude-plugin/README.md) ships that same reference as a skill so an agent can author cultures with the discipline already in working memory.
118
+
119
+ ## Examples included
120
+
121
+ - `sandbox-*`: probes of Claude Code's sandbox behavior across filesystem, network, IPC, and escape-hatch settings.
122
+ - `permissions-*`: how permission prompts behave under different settings.
123
+ - `guidance-*`: prompt-layer interventions for sandbox-safe path selection.
124
+ - `parser-*`: Claude Code's permission parser boundary cases.
125
+
126
+ ## License
127
+
128
+ MIT. See LICENSE.
data/bin/petri-dish ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "petri_dish"
5
+
6
+ PetriDish::CLI.new(ARGV).run
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # Hook event logger: reads JSON from stdin, adds timestamp, appends to JSONL file.
3
+ # Attached to all non-decision hook event types: PreToolUse, PostToolUse,
4
+ # UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact, SessionStart,
5
+ # SessionEnd, PermissionDenied.
6
+ #
7
+ # Environment:
8
+ # PETRIDISH_HOOK_LOG_FILE - path to append JSONL entries (required)
9
+
10
+ set -euo pipefail
11
+
12
+ LOG_FILE="${PETRIDISH_HOOK_LOG_FILE:?PETRIDISH_HOOK_LOG_FILE must be set}"
13
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
14
+
15
+ PAYLOAD=$(cat)
16
+
17
+ printf '{"ts":"%s","payload":%s}\n' "$TS" "$PAYLOAD" >> "$LOG_FILE"
18
+
19
+ exit 0
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ # Permission handler: logs PermissionRequest/PermissionDenied events and
3
+ # returns allow/deny decision for PermissionRequest.
4
+ #
5
+ # Environment:
6
+ # PETRIDISH_HOOK_LOG_FILE - path to append JSONL entries (required)
7
+ # PETRIDISH_PERMISSION_MODE - "accept" or "deny" (default: accept)
8
+
9
+ set -euo pipefail
10
+
11
+ LOG_FILE="${PETRIDISH_HOOK_LOG_FILE:?PETRIDISH_HOOK_LOG_FILE must be set}"
12
+ MODE="${PETRIDISH_PERMISSION_MODE:-accept}"
13
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
14
+
15
+ PAYLOAD=$(cat)
16
+
17
+ # Log the event
18
+ printf '{"ts":"%s","payload":%s}\n' "$TS" "$PAYLOAD" >> "$LOG_FILE"
19
+
20
+ # Extract event name to decide whether to return a decision
21
+ EVENT_NAME=$(printf '%s' "$PAYLOAD" | grep -o '"hook_event_name":"[^"]*"' | cut -d'"' -f4)
22
+
23
+ if [ "$EVENT_NAME" = "PermissionRequest" ]; then
24
+ if [ "$MODE" = "deny" ]; then
25
+ cat <<'DENY'
26
+ {"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied by petri-dish"}}}
27
+ DENY
28
+ else
29
+ cat <<'ALLOW'
30
+ {"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
31
+ ALLOW
32
+ fi
33
+ fi
34
+
35
+ exit 0
data/lib/petri-dish.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "petri_dish"
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "fileutils"
5
+
6
+ module PetriDish
7
+ class CLI
8
+ def initialize(argv)
9
+ @argv = argv
10
+ end
11
+
12
+ def run
13
+ if @argv.empty?
14
+ usage
15
+ exit 1
16
+ end
17
+
18
+ command = @argv.shift
19
+ case command
20
+ when "run" then cmd_run
21
+ when "list" then cmd_list
22
+ when "results" then cmd_results
23
+ when "setup" then cmd_setup
24
+ when "help", "--help", "-h"
25
+ usage
26
+ else
27
+ $stderr.puts "Unknown command: #{command}"
28
+ usage
29
+ exit 1
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def cultures_dir
36
+ @cultures_dir ||= ENV["PETRIDISH_CULTURES_DIR"] || File.join(Dir.pwd, "cultures")
37
+ end
38
+
39
+ def results_dir
40
+ @results_dir ||= File.join(Dir.pwd, "results")
41
+ end
42
+
43
+ def parse_cultures_dir_flag!
44
+ parser = OptionParser.new do |opts|
45
+ opts.on("--cultures-dir DIR", "Override the cultures directory") { |d| @cultures_dir = d }
46
+ end
47
+ parser.order!(@argv)
48
+ end
49
+
50
+ def cmd_run
51
+ parse_cultures_dir_flag!
52
+ options = { deny: false, debug: false, keep: false }
53
+ parser = OptionParser.new do |opts|
54
+ opts.on("--deny", "Deny all permission prompts") { options[:deny] = true }
55
+ opts.on("--debug", "Show hook events in real time") { options[:debug] = true }
56
+ opts.on("--keep", "Don't kill tmux on completion") { options[:keep] = true }
57
+ end
58
+ parser.parse!(@argv)
59
+
60
+ test_name = @argv.shift
61
+ unless test_name
62
+ $stderr.puts "Usage: petri-dish run <culture> [options]"
63
+ exit 1
64
+ end
65
+
66
+ validate_test!(test_name)
67
+ runner = Runner.new(test_name, cultures_dir: cultures_dir, results_dir: results_dir, **options)
68
+ runner.run!
69
+ end
70
+
71
+ def cmd_list
72
+ parse_cultures_dir_flag!
73
+ puts "Available cultures in #{cultures_dir}:\n\n"
74
+ each_test do |name, config|
75
+ puts " \e[32m#{name}\e[0m"
76
+ puts " #{config.description}"
77
+ puts " env: #{config.environment[:name]}, mode: #{config.prompt_mode}, timeout: #{config.runtime[:timeout]}s"
78
+ puts ""
79
+ end
80
+ end
81
+
82
+ def cmd_results
83
+ test_filter = @argv.shift
84
+
85
+ unless File.directory?(results_dir)
86
+ puts "No results yet."
87
+ return
88
+ end
89
+
90
+ Dir.glob("#{results_dir}/*/").sort.each do |test_dir|
91
+ test_name = File.basename(test_dir)
92
+ next if test_filter && test_name != test_filter
93
+
94
+ runs = Dir.glob("#{test_dir}/*/").sort.reverse
95
+ next if runs.empty?
96
+
97
+ puts "\e[32m#{test_name}\e[0m (#{runs.size} run#{'s' if runs.size != 1})"
98
+ runs.each do |run_dir|
99
+ timestamp = File.basename(run_dir)
100
+ has_results = File.exist?(File.join(run_dir, "results.md"))
101
+ has_transcript = File.exist?(File.join(run_dir, "transcript.log"))
102
+ status = has_results ? "results" : "no results"
103
+ status += ", transcript" if has_transcript
104
+ puts " #{timestamp} (#{status})"
105
+ end
106
+ puts ""
107
+ end
108
+ end
109
+
110
+ def cmd_setup
111
+ clean = @argv.delete("--clean")
112
+ parse_cultures_dir_flag!
113
+ test_filter = @argv.shift
114
+
115
+ tests = if test_filter
116
+ validate_test!(test_filter)
117
+ [test_filter]
118
+ else
119
+ available_tests
120
+ end
121
+
122
+ if clean
123
+ tests.each { |name| teardown_test(name) }
124
+ else
125
+ tests.each { |name| setup_test(name) }
126
+ end
127
+ end
128
+
129
+ def setup_test(test_name)
130
+ config = Config.new(File.join(cultures_dir, test_name))
131
+ env = Environment.new(config.environment[:name])
132
+
133
+ puts "\e[32m[setup]\e[0m Setting up: #{test_name}"
134
+
135
+ if config.runtime[:work_dir] == "/tmp/sandbox-test"
136
+ setup_scratch_project
137
+ end
138
+
139
+ env.create!
140
+ env.merge_settings!(config.environment[:settings])
141
+ env.inject_hooks!(prompt_mode: config.prompt_mode)
142
+ env.trust!(config.runtime[:work_dir])
143
+
144
+ config.environment[:plugins].each do |plugin|
145
+ env.install_plugin!(marketplace: plugin[:marketplace], plugin: plugin[:plugin])
146
+ end
147
+
148
+ puts "\e[32m[setup]\e[0m #{test_name} ready\n\n"
149
+ end
150
+
151
+ def teardown_test(test_name)
152
+ config = Config.new(File.join(cultures_dir, test_name))
153
+ env = Environment.new(config.environment[:name])
154
+ puts "\e[32m[setup]\e[0m Cleaning: #{test_name}"
155
+ env.clean!
156
+ end
157
+
158
+ def setup_scratch_project
159
+ scratch = "/tmp/sandbox-test"
160
+ if File.directory?(File.join(scratch, ".git"))
161
+ puts "\e[32m[setup]\e[0m Scratch project already exists"
162
+ return
163
+ end
164
+
165
+ puts "\e[32m[setup]\e[0m Creating scratch project at #{scratch}"
166
+ FileUtils.mkdir_p(scratch)
167
+ system("git", "-C", scratch, "init")
168
+ File.write(File.join(scratch, "README.md"), "# Sandbox Test Project\n")
169
+ system("git", "-C", scratch, "add", "README.md")
170
+ system("git", "-C", scratch, "commit", "-m", "init")
171
+ FileUtils.mkdir_p(File.join(scratch, ".claude"))
172
+ end
173
+
174
+ def validate_test!(name)
175
+ test_dir = File.join(cultures_dir, name)
176
+ unless File.directory?(test_dir)
177
+ $stderr.puts "Culture not found: #{name}"
178
+ $stderr.puts "Cultures dir: #{cultures_dir}"
179
+ $stderr.puts "Available cultures: #{available_tests.join(', ')}"
180
+ exit 1
181
+ end
182
+ end
183
+
184
+ def available_tests
185
+ Dir.glob("#{cultures_dir}/*/config.yml").map { |p| File.basename(File.dirname(p)) }.sort
186
+ end
187
+
188
+ def each_test
189
+ available_tests.each do |name|
190
+ config = Config.new(File.join(cultures_dir, name))
191
+ yield name, config
192
+ end
193
+ end
194
+
195
+ def usage
196
+ puts <<~USAGE
197
+ Usage: petri-dish <command> [options]
198
+
199
+ Commands:
200
+ run <culture> [--deny] [--debug] [--keep] [--cultures-dir DIR]
201
+ list List available cultures
202
+ results [culture] Show past results
203
+ setup [culture] Create environments
204
+ setup --clean [culture] Tear down environments
205
+ help Show this help
206
+
207
+ Cultures directory resolution (in order):
208
+ 1. --cultures-dir flag
209
+ 2. $PETRIDISH_CULTURES_DIR environment variable
210
+ 3. ./cultures/ relative to current directory
211
+ USAGE
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module PetriDish
7
+ class Config
8
+ REQUIRED_KEYS = %w[name description environment runtime].freeze
9
+ REQUIRED_ENV_KEYS = %w[name].freeze
10
+ VALID_PROMPT_MODES = %w[accept deny].freeze
11
+
12
+ attr_reader :name, :description, :environment, :runtime, :prompt_mode, :test_dir
13
+
14
+ def initialize(test_dir)
15
+ @test_dir = Pathname.new(test_dir).expand_path
16
+ config_path = @test_dir / "config.yml"
17
+ raise "Config not found: #{config_path}" unless config_path.exist?
18
+
19
+ data = YAML.safe_load(config_path.read, permitted_classes: [Symbol])
20
+ validate!(data)
21
+
22
+ @name = data["name"]
23
+ @description = data["description"]
24
+ @environment = parse_environment(data["environment"])
25
+ @runtime = parse_runtime(data["runtime"])
26
+ @prompt_mode = parse_prompt_mode(data["prompt_mode"])
27
+ end
28
+
29
+ def prompt_path
30
+ test_dir / "prompt.md"
31
+ end
32
+
33
+ private
34
+
35
+ def validate!(data)
36
+ REQUIRED_KEYS.each do |key|
37
+ raise "Missing required key: #{key}" unless data.key?(key)
38
+ end
39
+ REQUIRED_ENV_KEYS.each do |key|
40
+ raise "Missing environment.#{key}" unless data.dig("environment", key)
41
+ end
42
+ end
43
+
44
+ def parse_environment(env)
45
+ {
46
+ name: env["name"],
47
+ plugins: (env["plugins"] || []).map { |p| { marketplace: p["marketplace"], plugin: p["plugin"] } },
48
+ settings: env["settings"] || {}
49
+ }
50
+ end
51
+
52
+ def parse_runtime(rt)
53
+ {
54
+ work_dir: expand_home(rt["work_dir"] || "."),
55
+ preamble: rt["preamble"],
56
+ inject_results_file: rt.fetch("inject_results_file", true),
57
+ part_suffix: rt["part_suffix"],
58
+ model: rt["model"],
59
+ timeout: rt.fetch("timeout", 300),
60
+ prepare: Array(rt["prepare"])
61
+ }
62
+ end
63
+
64
+ def parse_prompt_mode(mode)
65
+ mode ||= "accept"
66
+ raise "Invalid prompt_mode: #{mode}" unless VALID_PROMPT_MODES.include?(mode)
67
+
68
+ mode
69
+ end
70
+
71
+ def expand_home(path)
72
+ path.sub(/\A~/, Dir.home)
73
+ end
74
+ end
75
+ end