agent-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.
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "shellwords"
6
+ require "tempfile"
7
+ require_relative "config"
8
+ require_relative "environment"
9
+ require_relative "hook_log"
10
+ require_relative "results_builder"
11
+ require_relative "transcript"
12
+
13
+ module PetriDish
14
+ class StartupFailure < StandardError; end
15
+ class AuthFailure < StandardError; end
16
+
17
+ class Runner
18
+ POLL_INTERVAL = 2 # seconds
19
+ STARTUP_DEADLINE = 60 # seconds, must see a SessionStart event by then or fail loud
20
+ AUTH_ERROR_PATTERN = %r{API Error: 40[13]|Please run /login|Invalid authentication credentials|authentication_error}
21
+
22
+ def initialize(test_name, cultures_dir:, results_dir:, deny: false, debug: false, keep: false)
23
+ @cultures_dir = cultures_dir
24
+ @results_dir = results_dir
25
+ @test_dir = File.join(cultures_dir, test_name)
26
+ @config = Config.new(@test_dir)
27
+ @deny = deny
28
+ @debug = debug
29
+ @keep = keep
30
+ @tmux_session = "test-#{@config.name}"
31
+ end
32
+
33
+ def run!
34
+ preflight!
35
+ ensure_env!
36
+ run_prepare!
37
+
38
+ env = Environment.new(@config.environment[:name])
39
+
40
+ # Re-trust after prepare. The setup-time trust call runs before the
41
+ # work_dir exists, so File.realpath falls back to expand_path and stores
42
+ # the trust key under /tmp/... — but Claude resolves symlinks and looks
43
+ # under /private/tmp/... on macOS. Re-trusting here, with the path now
44
+ # existing, lets realpath canonicalize properly.
45
+ env.trust!(@config.runtime[:work_dir])
46
+
47
+ results_dir = create_results_dir
48
+ signal_file = File.join(results_dir, "signal")
49
+ transcript_path = File.join(results_dir, "transcript.log")
50
+
51
+ env.clear_hook_log!
52
+
53
+ prompt = build_prompt(signal_file)
54
+ launch_tmux!(prompt)
55
+
56
+ failure = nil
57
+ begin
58
+ poll_for_completion(signal_file, env)
59
+ rescue Interrupt
60
+ log "Interrupted by user"
61
+ rescue StartupFailure, AuthFailure => e
62
+ failure = e
63
+ end
64
+
65
+ # Capture transcript first. If we failed, tmux may be dying and we
66
+ # want whatever output Claude printed (auth error, version mismatch, etc.).
67
+ transcript = Transcript.new(@tmux_session)
68
+ transcript.save!(transcript_path) if tmux_alive?
69
+
70
+ # Collect artifacts
71
+ hook_log_dest = File.join(results_dir, "hook-events.jsonl")
72
+ FileUtils.cp(env.hook_log_path, hook_log_dest) if File.exist?(env.hook_log_path)
73
+
74
+ if failure
75
+ # Tear down tmux now so the error message isn't competing with a live session.
76
+ system("tmux kill-session -t #{@tmux_session} 2>/dev/null")
77
+ report_failure!(failure, transcript_path, results_dir)
78
+ exit 1
79
+ end
80
+
81
+ # Build results
82
+ if File.exist?(hook_log_dest)
83
+ builder = ResultsBuilder.new(hook_log_dest, transcript_path, results_dir)
84
+ builder.build!
85
+ end
86
+
87
+ # Teardown
88
+ unless @keep
89
+ system("tmux kill-session -t #{@tmux_session} 2>/dev/null")
90
+ end
91
+
92
+ # Summary
93
+ results_file = File.join(results_dir, "results.md")
94
+ log ""
95
+ log "Run complete: #{@config.name}"
96
+ log " Results: #{File.exist?(results_file) ? results_file : '(not written)'}"
97
+ log " Hook log: #{File.exist?(hook_log_dest) ? hook_log_dest : '(not captured)'}"
98
+ log " Transcript: #{File.exist?(transcript_path) ? transcript_path : '(not captured)'}"
99
+ if @keep
100
+ log " tmux: tmux attach -t #{@tmux_session}"
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def run_prepare!
107
+ commands = @config.runtime[:prepare] || []
108
+ return if commands.empty?
109
+
110
+ log "Running #{commands.length} prepare step(s)"
111
+ commands.each do |cmd|
112
+ log " prepare: #{cmd}"
113
+ raise "prepare step failed: #{cmd}" unless system(cmd)
114
+ end
115
+ end
116
+
117
+ def preflight!
118
+ %w[claude tmux].each do |cmd|
119
+ unless system("command -v #{cmd} > /dev/null 2>&1")
120
+ raise "Required command not found: #{cmd}"
121
+ end
122
+ end
123
+ end
124
+
125
+ def ensure_env!
126
+ env = Environment.new(@config.environment[:name])
127
+ unless env.exists?
128
+ raise "Environment '#{@config.environment[:name]}' not found. Run: petri-dish setup #{@config.name}"
129
+ end
130
+ end
131
+
132
+ def create_results_dir
133
+ timestamp = Time.now.strftime("%Y-%m-%dT%H-%M-%S")
134
+ base = File.join(@results_dir, @config.name, timestamp)
135
+ # Guard against collisions: fast cultures (~20-40s) can finish multiple
136
+ # runs in the same second. Append a counter so a later run never clobbers
137
+ # an earlier one's dir (and races its half-written hook-events.jsonl).
138
+ dir = base
139
+ counter = 1
140
+ while File.exist?(dir)
141
+ counter += 1
142
+ dir = "#{base}-#{counter}"
143
+ end
144
+ FileUtils.mkdir_p(dir)
145
+ dir
146
+ end
147
+
148
+ def build_prompt(signal_file)
149
+ parts = []
150
+
151
+ # Preamble
152
+ if @config.runtime[:preamble]
153
+ preamble_path = resolve_preamble(@config.runtime[:preamble])
154
+ if preamble_path
155
+ parts << File.read(preamble_path)
156
+ parts << "\n---\n"
157
+ end
158
+ end
159
+
160
+ # Main prompt
161
+ prompt_path = @config.prompt_path
162
+ raise "Prompt not found: #{prompt_path}" unless prompt_path.exist?
163
+ parts << prompt_path.read
164
+
165
+ # Part suffix
166
+ if @config.runtime[:part_suffix]
167
+ parts << "\n---\n"
168
+ parts << "**INSTRUCTION: #{@config.runtime[:part_suffix]}**"
169
+ end
170
+
171
+ # Signal file injection
172
+ if @config.runtime[:inject_results_file]
173
+ parts << "\n---\n"
174
+ parts << "**SIGNAL_FILE: #{signal_file}**"
175
+ end
176
+
177
+ parts.join("\n")
178
+ end
179
+
180
+ def launch_tmux!(prompt)
181
+ system("tmux kill-session -t #{@tmux_session} 2>/dev/null")
182
+
183
+ work_dir = @config.runtime[:work_dir]
184
+ env_dir = Environment.new(@config.environment[:name]).env_path
185
+
186
+ prompt_file = Tempfile.new(["petri-dish-prompt-", ".md"], Dir.tmpdir)
187
+ prompt_file.write(prompt)
188
+ prompt_file.close
189
+
190
+ model_flag = @config.runtime[:model] ? " --model #{@config.runtime[:model]}" : ""
191
+ launcher = Tempfile.new(["petri-dish-launcher-", ".sh"], Dir.tmpdir)
192
+ launcher.write(<<~SH)
193
+ #!/usr/bin/env bash
194
+ exec env CLAUDE_CONFIG_DIR='#{env_dir}' ENABLE_CLAUDEAI_MCP_SERVERS='false' claude#{model_flag} "$(cat '#{prompt_file.path}')"
195
+ SH
196
+ launcher.close
197
+ File.chmod(0o755, launcher.path)
198
+
199
+ @_prompt_file = prompt_file
200
+ @_launcher = launcher
201
+
202
+ system("tmux new-session -d -s #{@tmux_session} -c #{work_dir.shellescape}")
203
+ system("tmux send-keys -t #{@tmux_session} #{launcher.path.shellescape} Enter")
204
+
205
+ log "Claude launched in tmux session '#{@tmux_session}'"
206
+ log "Attach to watch: tmux attach -t #{@tmux_session}"
207
+
208
+ sleep 3
209
+ end
210
+
211
+ def poll_for_completion(signal_file, env)
212
+ timeout = @config.runtime[:timeout]
213
+ start = Time.now
214
+ hook_log_lines_seen = 0
215
+ startup_confirmed = false
216
+
217
+ log "Polling for completion (timeout: #{timeout}s)"
218
+
219
+ loop do
220
+ unless tmux_alive?
221
+ unless startup_confirmed
222
+ raise StartupFailure, "tmux session exited before any SessionStart hook event fired"
223
+ end
224
+ break
225
+ end
226
+
227
+ if File.exist?(signal_file) && File.size(signal_file) > 0
228
+ log "Signal file detected. Session complete."
229
+ break
230
+ end
231
+
232
+ # Watch for SessionStart in the hook log, firmest signal that Claude
233
+ # actually booted (vs. exited on auth failure / version mismatch / etc.).
234
+ if !startup_confirmed && File.exist?(env.hook_log_path)
235
+ if File.read(env.hook_log_path).include?('"hook_event_name":"SessionStart"')
236
+ startup_confirmed = true
237
+ log "Session started (SessionStart event observed)"
238
+ end
239
+ end
240
+
241
+ # Once booted, watch the visible pane for mid-session auth failures.
242
+ # Claude fires SessionStart before its first API call, so an OAuth-stale
243
+ # session can confirm startup, hit 401/403, then sit waiting for input.
244
+ if startup_confirmed && (match = AUTH_ERROR_PATTERN.match(Transcript.new(@tmux_session).capture_visible))
245
+ raise AuthFailure, "API auth error mid-session: #{match[0]}"
246
+ end
247
+
248
+ elapsed = Time.now - start
249
+ if !startup_confirmed && elapsed >= STARTUP_DEADLINE
250
+ raise StartupFailure, "no SessionStart event within #{STARTUP_DEADLINE}s (tmux still alive, Claude may be stuck at a login prompt)"
251
+ end
252
+
253
+ if elapsed >= timeout
254
+ log "Timeout reached (#{timeout}s). Stopping."
255
+ break
256
+ end
257
+
258
+ # Debug: print new hook events as they arrive
259
+ if @debug && File.exist?(env.hook_log_path)
260
+ lines = File.readlines(env.hook_log_path)
261
+ lines[hook_log_lines_seen..].each do |line|
262
+ data = JSON.parse(line) rescue next
263
+ p = data["payload"]
264
+ event = p["hook_event_name"]
265
+ tool = p["tool_name"] || ""
266
+ cmd = p.dig("tool_input", "command") || ""
267
+ $stderr.puts "\e[2m[hook] #{event} #{tool} #{cmd[0..60]}\e[0m"
268
+ end
269
+ hook_log_lines_seen = lines.size
270
+ end
271
+
272
+ sleep POLL_INTERVAL
273
+ end
274
+ end
275
+
276
+ def report_failure!(err, transcript_path, results_dir)
277
+ label = err.is_a?(AuthFailure) ? "AUTH FAILURE" : "STARTUP FAILURE"
278
+ marker_file = err.is_a?(AuthFailure) ? "AUTH_FAILURE.txt" : "STARTUP_FAILURE.txt"
279
+
280
+ $stderr.puts ""
281
+ $stderr.puts "\e[31m[runner] #{label}\e[0m"
282
+ $stderr.puts " #{err.message}"
283
+ $stderr.puts ""
284
+ $stderr.puts " Likely causes:"
285
+ $stderr.puts " - Stale OAuth credentials in the cenv environment."
286
+ $stderr.puts " - Claude Code version installed for this env can't auth."
287
+ $stderr.puts " - claude binary missing from PATH inside tmux."
288
+ $stderr.puts ""
289
+ $stderr.puts " Recovery:"
290
+ $stderr.puts " petri-dish setup --clean #{@config.name}"
291
+ $stderr.puts " petri-dish setup #{@config.name}"
292
+ $stderr.puts " cenv login #{@config.environment[:name]} # if a /login prompt was shown"
293
+ $stderr.puts ""
294
+ if File.exist?(transcript_path) && File.size(transcript_path) > 0
295
+ $stderr.puts " Captured tmux output: #{transcript_path}"
296
+ last = File.read(transcript_path).lines.last(20).join
297
+ $stderr.puts " --- last 20 lines ---"
298
+ $stderr.puts last.gsub(/^/, " ")
299
+ else
300
+ $stderr.puts " No tmux output captured (session may have died before printing)."
301
+ end
302
+ $stderr.puts ""
303
+ # Drop a marker so the empty results dir is self-explanatory later.
304
+ File.write(File.join(results_dir, marker_file), "#{err.message}\n")
305
+ end
306
+
307
+ def resolve_preamble(name)
308
+ # Legacy configs use paths like "lib/preambles/sandbox.md". Strip the prefix
309
+ # so the bare name maps to gem-bundled lib/petri_dish/preambles/<name>.md, but
310
+ # still allow user-provided overrides in the cultures_dir.
311
+ bare = name.sub(%r{\Alib/preambles/}, "")
312
+
313
+ candidates = [
314
+ File.join(@cultures_dir, name),
315
+ File.join(@cultures_dir, bare),
316
+ File.join(PetriDish.root, "lib", "petri_dish", "preambles", bare)
317
+ ]
318
+
319
+ candidates.find { |p| File.exist?(p) }
320
+ end
321
+
322
+ def tmux_alive?
323
+ system("tmux has-session -t #{@tmux_session} 2>/dev/null")
324
+ end
325
+
326
+ def log(msg)
327
+ puts "\e[32m[runner]\e[0m #{msg}"
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriDish
4
+ class Transcript
5
+ ANSI_REGEX = /\e\[[0-9;]*[a-zA-Z]/
6
+
7
+ def initialize(tmux_session)
8
+ @tmux_session = tmux_session
9
+ end
10
+
11
+ def capture_pane(visible_only: false)
12
+ flag = visible_only ? "" : "-S -"
13
+ raw = `tmux capture-pane -t #{@tmux_session} -p #{flag} 2>/dev/null`
14
+ strip_ansi(raw)
15
+ end
16
+
17
+ def capture_visible
18
+ capture_pane(visible_only: true)
19
+ end
20
+
21
+ def save!(output_path)
22
+ content = capture_pane
23
+ File.write(output_path, content)
24
+ log "Transcript saved to #{output_path}"
25
+ end
26
+
27
+ private
28
+
29
+ def strip_ansi(text)
30
+ text.gsub(ANSI_REGEX, "")
31
+ end
32
+
33
+ def log(msg)
34
+ puts "\e[32m[transcript]\e[0m #{msg}"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriDish
4
+ VERSION = "0.1.0"
5
+ end
data/lib/petri_dish.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "petri_dish/version"
4
+
5
+ module PetriDish
6
+ # Root directory of the installed gem. Used to locate ship-with assets
7
+ # (hooks, preambles). Distinct from user-provided cultures_dir and results_dir.
8
+ def self.root
9
+ File.expand_path("..", __dir__)
10
+ end
11
+ end
12
+
13
+ require_relative "petri_dish/config"
14
+ require_relative "petri_dish/environment"
15
+ require_relative "petri_dish/hook_log"
16
+ require_relative "petri_dish/results_builder"
17
+ require_relative "petri_dish/runner"
18
+ require_relative "petri_dish/transcript"
19
+ require_relative "petri_dish/cli"
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+ """Analyze hook event log and correlate with sidecar results."""
3
+ import json
4
+ import sys
5
+ from datetime import datetime
6
+
7
+ def parse_ts(ts_str):
8
+ """Parse ISO timestamp to datetime."""
9
+ # Handle both millisecond and second precision
10
+ for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
11
+ try:
12
+ return datetime.strptime(ts_str, fmt)
13
+ except ValueError:
14
+ continue
15
+ return None
16
+
17
+ def main():
18
+ hook_file = sys.argv[1] if len(sys.argv) > 1 else "/tmp/petri-dish-hooks.jsonl"
19
+
20
+ # Parse hook events
21
+ entries = []
22
+ with open(hook_file) as f:
23
+ for line in f:
24
+ line = line.strip()
25
+ if not line:
26
+ continue
27
+ data = json.loads(line)
28
+ entries.append(data)
29
+
30
+ # Pair Pre/Post by tool_use_id
31
+ pairs = {}
32
+ for entry in entries:
33
+ ts = parse_ts(entry["ts"])
34
+ p = entry["payload"]
35
+ event = p["hook_event_name"]
36
+ tid = p.get("tool_use_id")
37
+
38
+ if event == "Notification":
39
+ ntype = p.get("notification_type", "unknown")
40
+ msg = p.get("message", "")
41
+ print(f" NOTIFICATION: type={ntype} msg={msg[:60]}")
42
+ continue
43
+
44
+ if not tid:
45
+ continue
46
+
47
+ if event == "PreToolUse":
48
+ cmd = p.get("tool_input", {}).get("command", "")
49
+ tool = p.get("tool_name", "")
50
+ pairs[tid] = {"pre_ts": ts, "cmd": cmd, "tool": tool}
51
+ elif event == "PostToolUse" and tid in pairs:
52
+ pairs[tid]["post_ts"] = ts
53
+
54
+ # Prompted commands from results.md (PROMPTED_ALLOWED)
55
+ prompted_cmds = [
56
+ "for f in a.txt b.txt c.txt; do echo $f; done",
57
+ "for f in *.txt; do cat $f; done",
58
+ "echo $(whoami)",
59
+ "echo $(ls /tmp)",
60
+ "echo {1..5}",
61
+ "echo file-{a,b,c}.txt",
62
+ "cat <(echo hello)",
63
+ "{ echo a; echo b; }",
64
+ "echo $HOME",
65
+ "echo $PATH",
66
+ "echo $MY_CUSTOM_VAR",
67
+ ]
68
+
69
+ print()
70
+ print(f"{'#':<4} {'Command':<52} {'Delta':>7} {'Sidecar':<18}")
71
+ print("-" * 90)
72
+
73
+ i = 0
74
+ for tid, p in pairs.items():
75
+ if "post_ts" not in p:
76
+ continue
77
+ if p["tool"] != "Bash":
78
+ continue
79
+
80
+ i += 1
81
+ cmd = p["cmd"]
82
+ delta = (p["post_ts"] - p["pre_ts"]).total_seconds()
83
+
84
+ was_prompted = cmd in prompted_cmds
85
+ sidecar = "PROMPTED" if was_prompted else "silent"
86
+
87
+ # Truncate command for display
88
+ cmd_display = cmd[:50] + "..." if len(cmd) > 50 else cmd
89
+
90
+ print(f"{i:<4} {cmd_display:<52} {delta:>6.2f}s {sidecar:<18}")
91
+
92
+ # Summary stats
93
+ prompted_deltas = []
94
+ silent_deltas = []
95
+ for tid, p in pairs.items():
96
+ if "post_ts" not in p or p["tool"] != "Bash":
97
+ continue
98
+ delta = (p["post_ts"] - p["pre_ts"]).total_seconds()
99
+ if p["cmd"] in prompted_cmds:
100
+ prompted_deltas.append(delta)
101
+ else:
102
+ silent_deltas.append(delta)
103
+
104
+ print()
105
+ print("Summary:")
106
+ if silent_deltas:
107
+ print(f" Silent commands: avg={sum(silent_deltas)/len(silent_deltas):.2f}s "
108
+ f"min={min(silent_deltas):.2f}s max={max(silent_deltas):.2f}s n={len(silent_deltas)}")
109
+ if prompted_deltas:
110
+ print(f" Prompted commands: avg={sum(prompted_deltas)/len(prompted_deltas):.2f}s "
111
+ f"min={min(prompted_deltas):.2f}s max={max(prompted_deltas):.2f}s n={len(prompted_deltas)}")
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook: block any Bash command matching a regex pattern.
3
+ #
4
+ # Environment:
5
+ # HOOK_BLOCK_PATTERN - regex matched against tool_input.command (required)
6
+ # PETRIDISH_HOOK_LOG_FILE - JSONL path to log every invocation (optional)
7
+ #
8
+ # Reads the PreToolUse JSON payload on stdin. If the command matches
9
+ # HOOK_BLOCK_PATTERN, emits a JSON decision denying the call with
10
+ # exit 0 (documented mechanism). Otherwise exits 0 silently.
11
+
12
+ set -u
13
+
14
+ LOG_FILE="${PETRIDISH_HOOK_LOG_FILE:-/tmp/petri-hooks.jsonl}"
15
+ PATTERN="${HOOK_BLOCK_PATTERN:-}"
16
+
17
+ PAYLOAD=$(cat)
18
+
19
+ # Log every invocation with timestamp
20
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
21
+ printf '{"ts":"%s","payload":%s}\n' "$TS" "$PAYLOAD" >> "$LOG_FILE"
22
+
23
+ # No pattern configured: let everything through
24
+ if [ -z "$PATTERN" ]; then
25
+ exit 0
26
+ fi
27
+
28
+ # Only evaluate Bash tool calls
29
+ TOOL_NAME=$(printf '%s' "$PAYLOAD" | python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_name',''))")
30
+ if [ "$TOOL_NAME" != "Bash" ]; then
31
+ exit 0
32
+ fi
33
+
34
+ COMMAND=$(printf '%s' "$PAYLOAD" | python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))")
35
+
36
+ if printf '%s' "$COMMAND" | grep -qE "$PATTERN"; then
37
+ cat <<JSON
38
+ {
39
+ "hookSpecificOutput": {
40
+ "hookEventName": "PreToolUse",
41
+ "permissionDecision": "deny",
42
+ "permissionDecisionReason": "Blocked by policy hook: command matches HOOK_BLOCK_PATTERN"
43
+ }
44
+ }
45
+ JSON
46
+ exit 0
47
+ fi
48
+
49
+ exit 0
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # Hook logger: reads JSON from stdin, adds timestamp, appends to JSONL file.
3
+ # Usage: Configured as a Claude Code hook command.
4
+ #
5
+ # Environment:
6
+ # PETRIDISH_HOOK_LOG_FILE - path to append JSONL entries (default: /tmp/petri-hooks.jsonl)
7
+
8
+ LOG_FILE="${PETRIDISH_HOOK_LOG_FILE:-/tmp/petri-hooks.jsonl}"
9
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
10
+
11
+ # Read stdin (the hook JSON payload)
12
+ PAYLOAD=$(cat)
13
+
14
+ # Wrap with timestamp and write as one JSONL line
15
+ printf '{"ts":"%s","payload":%s}\n' "$TS" "$PAYLOAD" >> "$LOG_FILE"
16
+
17
+ # Exit 0: no decision, let normal flow continue
18
+ exit 0
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # inspect-session.sh — mine a Claude Code session JSONL for LSP-relevant content.
3
+ #
4
+ # Usage:
5
+ # scripts/inspect-session.sh <path-to-session.jsonl>
6
+ #
7
+ # The session JSONL lives at:
8
+ # ~/.local/share/cenv/<env>/projects/<cwd-slug>/<session-id>.jsonl
9
+ #
10
+ # Reports: attachment types, user-message content matching diagnostic patterns,
11
+ # and LSP tool invocations (operation + position).
12
+
13
+ set -euo pipefail
14
+
15
+ SESSION="${1:?usage: inspect-session.sh <session.jsonl>}"
16
+
17
+ if [ ! -f "$SESSION" ]; then
18
+ echo "error: session file not found: $SESSION" >&2
19
+ exit 1
20
+ fi
21
+
22
+ echo "=== Session: $SESSION ==="
23
+ echo
24
+
25
+ echo "=== Entry type counts ==="
26
+ jq -r '.type' "$SESSION" | sort | uniq -c
27
+ echo
28
+
29
+ echo "=== Attachment types ==="
30
+ jq -r 'select(.type == "attachment") | .attachment.type // (.attachment | keys | join(","))' "$SESSION" \
31
+ | sort | uniq -c
32
+ echo
33
+
34
+ echo "=== User-role content matching diagnostic/lsp patterns ==="
35
+ jq -r 'select(.type == "user") | .message.content | if type == "array" then map(.text // (.content // "") | tostring) | join(" | ") else . end' "$SESSION" \
36
+ | grep -iE 'diagnost|lsp |offense|warning:|error:' \
37
+ | head -20 || echo "(no matches)"
38
+ echo
39
+
40
+ echo "=== LSP tool invocations (assistant tool_use entries) ==="
41
+ jq -r 'select(.type == "assistant") | .message.content | if type == "array" then map(select(.type == "tool_use" and .name == "LSP")) else [] end | .[] | "\(.input.operation // "?") @ \(.input.filePath // "?"):\(.input.line // "?"):\(.input.character // "?")"' "$SESSION" \
42
+ || echo "(no LSP tool calls)"
43
+ echo
44
+
45
+ echo "=== Any 'lsp_diagnostics' or 'lspDiagnostics' anywhere in raw content ==="
46
+ grep -ciE 'lsp_diagnostics|lspDiagnostics' "$SESSION" || true
47
+ echo
48
+
49
+ echo "=== Tool counts (PreToolUse equivalent — assistant tool_use by name) ==="
50
+ jq -r 'select(.type == "assistant") | .message.content | if type == "array" then map(select(.type == "tool_use") | .name) else [] end | .[]' "$SESSION" \
51
+ | sort | uniq -c