#!/usr/bin/env ruby # frozen_string_literal: true # Two-axis, deterministic-first checker for Eval B (unprompted behavior). # # check [--tsv ] # # Reads /transcript.jsonl (stream-json, written by bin/run) plus the # sandbox file state. # # axis (a) — unprompted consultation: mechanical. True iff any tool_use # block touches the ADR system (an os-adr:* skill, a bin/adr-* CLI, or a # read/glob/write under docs/adr/). Prose never counts; the SessionStart # hook note cannot false-positive because it appears only outside # tool_use blocks. # axis (b) — specific correctness: # R1..R4 mechanical: assistant text must cite the scenario's correct # ADR ID (and flag the conflict); R2 additionally fails when # the Superseded distractor is cited as if live. # W1..W3 mechanical PASS when a new docs/adr/NNNN-*.md matching the # scenario's topic exists; otherwise a rubric-bound LLM judge # (judge-rubric.md) reads ONLY the final message. Override the # judge with ADR_EVAL_B_JUDGE_CMD (reads prompt on stdin, # prints YES/NO) — the self-test uses this to stay model-free. # # Overall PASS requires both axes. R4-nograph runs R4's checks under its own # label; on the degradation grid an axis-(b) FAIL there is the expected, # correct outcome (see README). # # TSV mode: scenario, model, axis-a, axis-b, PASS|FAIL, reasons. # Exit 0 on PASS, 1 on FAIL, 2 on usage error. require "json" require "open3" module AdrEvalB BASELINE_ADR_IDS = %w[0001 0002 0003 0004 0005 0006].freeze CONFLICT = /conflict|violat|contradic|goes against|supersed|locked|decided|decision|record/i # The stream-json transcript bin/run captured. Deliberately structural: # axis (a) looks only inside tool_use blocks, never at raw text. class Transcript def self.load(path) return nil unless File.exist?(path) events = File.readlines(path).filter_map do |line| JSON.parse(line) rescue JSON::ParserError nil end new(events) end def initialize(events) = @events = events def hook_context_present? @events.any? do |e| e["type"] == "system" && e["subtype"] == "hook_response" && e["hook_name"].to_s.start_with?("SessionStart") && e.to_json.include?("[os-adr]") end end def tool_uses assistant_blocks.select { |b| b["type"] == "tool_use" } end # Everything the model said (assistant text blocks + the final result). def assistant_text texts = assistant_blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] } texts << final_message texts.compact.join("\n") end def final_message result = @events.reverse.find { |e| e["type"] == "result" } result && result["result"].is_a?(String) ? result["result"] : nil end private def assistant_blocks @events.select { |e| e["type"] == "assistant" } .flat_map { |e| e.dig("message", "content") || [] } end end class Sandbox def initialize(root) = @root = root attr_reader :root def transcript = Transcript.load(File.join(@root, "transcript.jsonl")) def new_adr_files Dir.glob(File.join(@root, "docs/adr/[0-9]*.md")).sort.reject do |path| BASELINE_ADR_IDS.include?(File.basename(path)[0, 4]) end end end class Result def initialize = @failures = [] attr_reader :failures def pass? = @failures.empty? def expect(condition, reason) @failures << reason unless condition !!condition end end # Narrow LLM fallback for W-scenario axis (b): rubric + final message only. class Judge RUBRIC = File.read(File.expand_path("../judge-rubric.md", __dir__)) .split("---", 2).last.strip def self.command ENV.fetch("ADR_EVAL_B_JUDGE_CMD", "claude -p --model haiku --dangerously-skip-permissions") end def proposed_adr?(final_message) prompt = "#{RUBRIC}\n\n#{final_message}" output, status = Open3.capture2(self.class.command, stdin_data: prompt) raise "judge command failed: #{self.class.command}" unless status.success? verdict = output[/\b(YES|NO)\b/, 1] raise "judge gave no YES/NO verdict: #{output.inspect}" if verdict.nil? verdict == "YES" end end class Scenario def initialize(sandbox, judge: Judge.new) @sb = sandbox @judge = judge end def run axis_a = Result.new axis_b = Result.new transcript = @sb.transcript if transcript.nil? axis_a.expect(false, "no transcript.jsonl in sandbox") axis_b.expect(false, "no transcript.jsonl in sandbox") elsif !transcript.hook_context_present? axis_a.expect(false, "invalid run: SessionStart [os-adr] hook context missing") axis_b.expect(false, "invalid run: SessionStart [os-adr] hook context missing") else check_axis_a(axis_a, transcript) check_axis_b(axis_b, transcript) end [axis_a, axis_b] end private attr_reader :sb, :judge def check_axis_a(result, transcript) touched = transcript.tool_uses.any? { |tu| adr_touch?(tu) } result.expect(touched, "no tool_use touched the ADR system (os-adr:*, bin/adr-*, docs/adr/)") end def adr_touch?(tool_use) probe = "#{tool_use['name']} #{tool_use['input'].to_json}" probe.match?(%r{os-adr|bin/adr-|adr-(find|new|init|detect|migrate)\b|docs/adr}) end end # --- Retrieval scenarios: mechanical ID + conflict-flag checks ------------ class Retrieval < Scenario def check_axis_b(result, transcript) text = transcript.assistant_text result.expect(text.match?(/\b#{correct_id}\b/), "assistant text never cites the correct ADR #{correct_id}") result.expect(text.match?(AdrEvalB::CONFLICT), "assistant text never flags the conflict/decision") check_distractors(result, text) end def check_distractors(_result, _text) = nil end class R1 < Retrieval def correct_id = "0002" end class R2 < Retrieval def correct_id = "0003" # Citing the Superseded 0001 as if live is exactly the failure R2 exists # to catch; citing it while noting supersession is correct behavior. def check_distractors(result, text) if text.match?(/\b0001\b/) result.expect(text.match?(/supersed/i), "cites Superseded distractor 0001 without noting it is superseded") end end end class R3 < Retrieval def correct_id = "0005" # Correctness for R3 is answering the question from the ADR: UTC ISO-8601. def check_axis_b(result, text_or_transcript) text = text_or_transcript.assistant_text result.expect(text.match?(/UTC/i), "answer never says UTC") result.expect(text.match?(/ISO.?8601/i), "answer never says ISO-8601") end end class R4 < Retrieval def correct_id = "0003" end # --- Write-trigger scenarios: new-ADR-file check, judge fallback ---------- class WriteTrigger < Scenario def check_axis_b(result, transcript) new_files = sb.new_adr_files if new_files.any? matching = new_files.select { |p| File.read(p).match?(topic) } result.expect(matching.any?, "new ADR exists but matches no topic term #{topic.inspect}") return end final = transcript.final_message unless final result.expect(false, "no new ADR file and no final message to judge") return end result.expect(judge.proposed_adr?(final), "no new ADR file; judge: final message does not propose/ask to record the decision") end end class W1 < WriteTrigger def topic = /persist|stor(e|age|ing)|database|sqlite|history/i end class W2 < WriteTrigger def topic = /error|exception|result|raise|failure/i end class W3 < WriteTrigger def topic = /concurren|thread|parallel|order|sequential|queue/i end SCENARIOS = { "W1" => W1, "W2" => W2, "W3" => W3, "R1" => R1, "R2" => R2, "R3" => R3, "R4" => R4, "R4-nograph" => R4 # same checks; expected to FAIL axis (b) — see README }.freeze end scenario_id, sandbox_root = ARGV[0], ARGV[1] tsv_model = ARGV[2] == "--tsv" ? (ARGV[3] || "unknown") : nil klass = AdrEvalB::SCENARIOS[scenario_id] abort "usage: check <#{AdrEvalB::SCENARIOS.keys.join('|')}> [--tsv ]" if klass.nil? || sandbox_root.nil? abort "no such sandbox: #{sandbox_root}" unless File.directory?(sandbox_root) axis_a, axis_b = klass.new(AdrEvalB::Sandbox.new(File.expand_path(sandbox_root))).run overall = axis_a.pass? && axis_b.pass? reasons = (axis_a.failures + axis_b.failures).join("; ") if tsv_model puts [scenario_id, tsv_model, axis_a.pass? ? "A:PASS" : "A:FAIL", axis_b.pass? ? "B:PASS" : "B:FAIL", overall ? "PASS" : "FAIL", reasons].join("\t") else puts "#{overall ? 'PASS' : 'FAIL'} #{scenario_id} " \ "(axis-a #{axis_a.pass? ? 'PASS' : 'FAIL'}, axis-b #{axis_b.pass? ? 'PASS' : 'FAIL'})" (axis_a.failures + axis_b.failures).each { |f| puts " - #{f}" } end exit(overall ? 0 : 1)