#!/usr/bin/env ruby # frozen_string_literal: true # Two-axis, deterministic-first checker for Eval C (ambiguity-ladder discrimination). # # check [--tsv ] # # Reads /transcript.jsonl (stream-json, written by bin/run). # # Scenario class: P* (positive) = should consult/trigger ADR system # N* (negative) = should NOT over-trigger # # For POSITIVE scenarios: # axis (a) — unprompted consultation: mechanical. True iff any tool_use # block touches the ADR system (os-adr:* skill, bin/adr-* CLI, or # read/glob under docs/adr/). # axis (b) — specific correctness: mechanical new-ADR-file check matching # the scenario's topic pattern, or judge fallback if not found. # # For NEGATIVE scenarios: # axis (a) — over-trigger detection: mechanical. True iff NO tool_use # touched the ADR system (inverse of positive). # axis (b) — no false constraint assertion: mechanical check that no new # ADR file was created matching the scenario's topic, or judge fallback. # # Hook context is a precondition: invalid runs (no SessionStart hook) FAIL both axes. # # Overall PASS requires both axes. TSV mode for autoresearch integration. # Exit 0 on PASS, 1 on FAIL, 2 on usage error. require "json" require "open3" module AdrEvalC BASELINE_ADR_IDS = %w[0001 0002 0003 0004 0005 0006].freeze CONFLICT = /conflict|violat|contradic|goes against|decision|record|architecture/i 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 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 class InformationalResult # For informational metrics (doesn't affect pass/fail, just recorded) def initialize(value) @value = value end attr_reader :value def pass? = true # Informational results never fail end class Judge RUBRIC = File.read(File.expand_path("../judge-rubric.md", __dir__)) .split("---", 2).last.strip def self.command ENV.fetch("ADR_EVAL_C_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) # Override in subclasses raise NotImplementedError, "subclasses must override check_axis_a" 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 # --- Positive scenarios: should trigger ADR system --- class PositiveScenario < Scenario # Axis (b) passes via any ONE of, in order: # 1. created — a new topical ADR file exists (the model recorded a decision) # 2. cited — the final message cites the GOVERNING Accepted ADR by id. # The run-set positives sit in already-decided territory (the task restates # what an Accepted ADR already decided), so per the shipped plugin wording the # correct behavior is find -> cite -> comply, NOT create a duplicate ADR. # This branch conforms the checker to the documented design ("consulting # and/or recording is correct"); without it the checker fails exactly the # behavior the negatives reward. # 3. judge — frozen rubric fallback: final message proposes recording. attr_reader :b_via private 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 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) } @b_via = "created" if matching.any? 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 if final.match?(governing) @b_via = "cited-governing" return end if judge.proposed_adr?(final) @b_via = "judge-proposed" else result.expect(false, "no new ADR, no governing-ADR citation #{governing.inspect} in final message; judge: does not propose recording") end end end class P1L1Execution < PositiveScenario def topic = /timeout|retry|backoff|exponential|attempt/i def governing = /\b0002\b/ end class P2L2Execution < PositiveScenario def topic = /escalat|termin|failure|review|abandon/i def governing = /\b0005\b/ end class P3L3Execution < PositiveScenario def topic = /abandon|finish|complet|reliability|guarant/i def governing = /\b000[256]\b/ end class P4L1Notifications < PositiveScenario def topic = /notif|route|service|webhook|event/i def governing = /\b0003\b/ end class P5L2Notifications < PositiveScenario def topic = /webhook|deliv|retry|notification/i def governing = /\b0003\b/ end class P6L3Notifications < PositiveScenario def topic = /notif|channel|routing|delivery|unified/i def governing = /\b0003\b/ end # --- Negative scenarios: should NOT create false ADRs or assert phantom constraints --- class NegativeScenario < Scenario def check_axis_a(result, transcript) # Axis (a) for negatives is INFORMATIONAL ONLY (doesn't affect pass/fail) # Record whether ADR system was consulted (yes/no) touched = transcript.tool_uses.any? { |tu| adr_touch?(tu) } # Return informational result (always "passes" but carries info value) @consultation_value = touched ? "yes" : "no" end attr_reader :consultation_value protected attr_reader :sb def record_citation(_result, text) # Citing an existing ADR is CORRECT behavior for these tasks: the negatives sit # deliberately in ADR-covered domains, and a truthful citation while documenting, # testing, or auditing is desired. Separating a true citation from a false # constraint claim requires semantic judgment a keyword match cannot provide, # so citation is recorded as informational only — never a FAIL. @citation_value = text.match?(/\b000[1-6]\b|ADR \d{4}/i) ? "yes" : "no" end attr_reader :citation_value end class N1L1Execution < NegativeScenario def check_axis_b(result, transcript) new_files = sb.new_adr_files result.expect(new_files.empty?, "over-trigger: created new ADR file when testing existing timeout logic") record_citation(result, transcript.assistant_text) end end class N2L2Execution < NegativeScenario def check_axis_b(result, transcript) new_files = sb.new_adr_files result.expect(new_files.empty?, "over-trigger: created new ADR file when documenting existing failure handling") record_citation(result, transcript.assistant_text) end end class N3L3Execution < NegativeScenario def check_axis_b(result, transcript) new_files = sb.new_adr_files result.expect(new_files.empty?, "over-trigger: created new ADR file when auditing existing lifecycle guarantees") record_citation(result, transcript.assistant_text) end end class N4L1Notifications < NegativeScenario def check_axis_b(result, transcript) new_files = sb.new_adr_files result.expect(new_files.empty?, "over-trigger: created new ADR file when debugging existing webhook delivery") record_citation(result, transcript.assistant_text) end end class N5L2Notifications < NegativeScenario def check_axis_b(result, transcript) new_files = sb.new_adr_files result.expect(new_files.empty?, "over-trigger: created new ADR file when testing existing notification failure handling") record_citation(result, transcript.assistant_text) end end class N6L3Notifications < NegativeScenario def check_axis_b(result, transcript) new_files = sb.new_adr_files result.expect(new_files.empty?, "over-trigger: created new ADR file when auditing existing notification audit trail") record_citation(result, transcript.assistant_text) end end SCENARIOS = { "P1-L1-execution" => P1L1Execution, "N1-L1-execution" => N1L1Execution, "P2-L2-execution" => P2L2Execution, "N2-L2-execution" => N2L2Execution, "P3-L3-execution" => P3L3Execution, "N3-L3-execution" => N3L3Execution, "P4-L1-notifications" => P4L1Notifications, "N4-L1-notifications" => N4L1Notifications, "P5-L2-notifications" => P5L2Notifications, "N5-L2-notifications" => N5L2Notifications, "P6-L3-notifications" => P6L3Notifications, "N6-L3-notifications" => N6L3Notifications, }.freeze end scenario_id, sandbox_root = ARGV[0], ARGV[1] tsv_model = ARGV[2] == "--tsv" ? (ARGV[3] || "unknown") : nil klass = AdrEvalC::SCENARIOS[scenario_id] abort "usage: check <#{AdrEvalC::SCENARIOS.keys.join('|')}> [--tsv ]" if klass.nil? || sandbox_root.nil? abort "no such sandbox: #{sandbox_root}" unless File.directory?(sandbox_root) checker = klass.new(AdrEvalC::Sandbox.new(File.expand_path(sandbox_root))) axis_a, axis_b = checker.run is_negative = scenario_id.start_with?("N") # For negatives, axis (a) is informational; overall depends on axis (b) only if is_negative overall = axis_b.pass? axis_a_label = "A:#{checker.consultation_value || 'unknown'}" else overall = axis_a.pass? && axis_b.pass? axis_a_label = axis_a.pass? ? "A:PASS" : "A:FAIL" end reasons = (axis_a.failures + axis_b.failures).join("; ") if is_negative && checker.respond_to?(:citation_value) && checker.citation_value reasons = ["cited-adr:#{checker.citation_value}", reasons].reject(&:empty?).join("; ") end if !is_negative && checker.respond_to?(:b_via) && checker.b_via reasons = ["B-via:#{checker.b_via}", reasons].reject(&:empty?).join("; ") end if tsv_model puts [scenario_id, tsv_model, axis_a_label, axis_b.pass? ? "B:PASS" : "B:FAIL", overall ? "PASS" : "FAIL", reasons].join("\t") else axis_a_desc = is_negative ? "consultation:#{checker.consultation_value || 'unknown'}" : (axis_a.pass? ? "PASS" : "FAIL") puts "#{overall ? 'PASS' : 'FAIL'} #{scenario_id} " \ "(axis-a #{axis_a_desc}, axis-b #{axis_b.pass? ? 'PASS' : 'FAIL'})" (axis_a.failures + axis_b.failures).each { |f| puts " - #{f}" } end exit(overall ? 0 : 1)