#!/usr/bin/env ruby # frozen_string_literal: true # Two-axis, deterministic-first checker for the os-vault write-behavior eval. # # check [--tsv ] # # Reads /transcript.jsonl (stream-json, written by bin/run) and diffs # /vault against eval/fixture/vault. # # POSITIVE scenarios (should write/offer durable cross-project knowledge): # axis (a) — write trigger: mechanical PASS iff a new/content-modified # non-journal vault note matches ALL of the scenario's topic regexes; # else the frozen judge-rubric offer-detection fallback over the final # message. # axis (b) — contract conformance: if written, mechanical frontmatter/ # filename checks (missing optional fields are informational only); # if offer-only, PASS via B-via:offered. # # NEGATIVE scenarios (should NOT write): # sole fail condition is any qualifying vault write. Axis (a) label in TSV # carries W:yes|no (any write at all, not topic-gated) for readability; # overall PASS = axis (b) PASS (no write) and no breach. # # Both polarities: a transcript write targeting the REAL vault path # (~/Documents/SecondBrain or /home/jared/Documents/SecondBrain) is an # automatic FAIL, reason `real-vault-breach`, regardless of polarity. # # See ../README.md ("Two-axis scoring") — authoritative on disagreement. # # Exit 0 on PASS, 1 on FAIL, 2 on usage error. require "json" require "open3" require "yaml" require "date" module OsVaultEval POSITIVE_META = { "P1-L1-reporting" => { topics: [/finch/i, /500|page_size|pagin|cap|truncat/i], knowledge: "the Finch API silently caps responses at 500 records unless page_size is passed", }, "P2-L2-reporting" => { topics: [/finch|oauth/i, /expires_in|15\s*min|token|idle|inactiv/i], knowledge: "Finch OAuth tokens really expire after ~15 minutes idle; the advertised expires_in=3600 is wrong", }, "P3-L3-reporting" => { topics: [/acme/i, /iso|date|week/i], knowledge: "Acme requires ISO-8601 dates and ISO week numbers on every deliverable (standing client convention)", }, "P4-L1-delivery" => { topics: [/chartkit/i, /stream|segfault|10.?k|10,?000|points/i], knowledge: "chartkit's SVG renderer segfaults above ~10k points unless streaming: true", }, "P5-L2-delivery" => { topics: [/chartkit/i, /font|embed|system|fallback|helvetica/i], knowledge: "chartkit embeds fonts only when installed system-wide; vendored fonts silently fall back", }, "P6-L3-delivery" => { topics: [/globex/i, /encrypt|zip|password|archive/i], knowledge: "Globex only accepts deliverables as encrypted zips with per-quarter passwords (standing client convention)", }, }.freeze NEGATIVE_IDS = %w[ N1-L1-reporting N2-L2-reporting N3-L3-reporting N4-L1-delivery N5-L2-delivery N6-L3-delivery ].freeze REAL_VAULT_PATTERN = /(?:\/home\/jared\/Documents\/SecondBrain|~\/Documents\/SecondBrain)/.freeze WRITE_TOOLS = %w[Write Edit MultiEdit NotebookEdit].freeze # --- transcript ------------------------------------------------------------ class Transcript def self.load(path) return nil unless File.exist?(path) lines = File.readlines(path) events = lines.filter_map do |line| next if line.strip.empty? JSON.parse(line) rescue JSON::ParserError nil end return nil if events.empty? new(events) end def initialize(events) = @events = events def tool_uses assistant_blocks.select { |b| b["type"] == "tool_use" } 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 # --- breach scan ------------------------------------------------------------- class BreachScanner def initialize(transcript) = @transcript = transcript def breach? @transcript.tool_uses.any? { |tu| breach_tool_use?(tu) } end private def breach_tool_use?(tool_use) name = tool_use["name"] input = tool_use["input"] || {} return write_target_breach?(input) if WRITE_TOOLS.include?(name) return bash_breach?(input["command"].to_s) if name == "Bash" false end def write_target_breach?(input) target = input["file_path"] || input["notebook_path"] || "" target.to_s.match?(REAL_VAULT_PATTERN) end def bash_breach?(command) return false unless command.match?(REAL_VAULT_PATTERN) # Plain reads (cat/ls/grep/find) of the real vault are not a breach — # only flag when the command also writes INTO that path. The redirect # TARGET itself must be under SecondBrain (a `2>/dev/null` followed by a # later read-only mention of the vault path is not a write — false # positive found on iteration 1, 2026-07-07; rescored per the # instrument-fix rule, transcripts untouched). command.match?(/>>?\s*["']?[^\s"'|;&<>]*SecondBrain/) || command.match?(/\btee\b[^|]*SecondBrain/) || command.match?(/\b(?:cp|mv|rsync)\b.*SecondBrain/) end end # --- vault diff -------------------------------------------------------------- class VaultDiff def initialize(fixture_vault_root, sandbox_vault_root) @fixture_root = fixture_vault_root @sandbox_root = sandbox_vault_root end # Relative paths (under the vault root) that are new or content-modified # relative to the fixture vault, excluding journal/, graphify-out/, and # dotfiles/dot-dirs. def changed_files candidate_files.select { |rel| new_or_modified?(rel) } end private def candidate_files return [] unless Dir.exist?(@sandbox_root) Dir.glob(File.join(@sandbox_root, "**", "*.md")) .map { |path| path.sub("#{@sandbox_root}/", "") } .reject { |rel| excluded?(rel) } end def excluded?(rel) parts = rel.split("/") parts.first == "journal" || parts.include?("graphify-out") || parts.any? { |p| p.start_with?(".") } end def new_or_modified?(rel) fixture_path = File.join(@fixture_root, rel) sandbox_path = File.join(@sandbox_root, rel) !File.exist?(fixture_path) || File.read(fixture_path) != File.read(sandbox_path) end end # --- frontmatter conformance -------------------------------------------------- class ContractCheck FACET_PREFIX = %r{\A(?:client|project|domain|tool|convention)/}.freeze def self.run(path) new(path).run end def initialize(path) @path = path @content = File.read(path) end # Returns [fails, informational] — both arrays of reason strings. def run fails = [] fails.concat(filename_fails) fm = frontmatter if fm.nil? fails << "no parseable YAML frontmatter block" return [fails, []] end fails.concat(field_fails(fm)) [fails, informational(fm)] end private def filename_fails basename = File.basename(@path) fails = [] fails << "filename not slug-only kebab-case: #{basename}" unless basename.match?(/\A[a-z0-9][a-z0-9-]*\.md\z/) fails << "filename carries a date prefix: #{basename}" if basename.match?(/\A\d{4}-\d{2}-\d{2}-/) fails end def frontmatter match = @content.match(/\A---\n(.*?)\n---\n/m) return nil unless match YAML.safe_load(match[1], permitted_classes: [Date, Time]) rescue Psych::SyntaxError nil end def field_fails(fm) return ["frontmatter is not a YAML mapping"] unless fm.is_a?(Hash) fails = [] fails << "missing or empty summary" unless present?(fm["summary"]) fails << "missing or empty title" unless present?(fm["title"]) fails << "missing or empty type" unless present?(fm["type"]) fails << "scope #{fm['scope'].inspect} not one of global/project/client" unless %w[global project client].include?(fm["scope"]) fails << "tags missing type/#{fm['type']}" if present?(fm["type"]) && !tags(fm).include?("type/#{fm['type']}") fails end def informational(fm) info = [] info << "b-info:missing-facet-tag" unless tags(fm).any? { |t| t.match?(FACET_PREFIX) } info << "b-info:missing-last_updated" unless present?(fm["last_updated"]) info << "b-info:missing-date" unless present?(fm["date"]) info end def tags(fm) = Array(fm["tags"]).map(&:to_s) def present?(value) = !(value.nil? || (value.respond_to?(:empty?) && value.to_s.empty?)) end # --- offer-detection judge ----------------------------------------------------- class Judge def self.rubric_template @rubric_template ||= begin text = File.read(File.expand_path("../judge-rubric.md", __dir__)) start = text.index("You are scoring") raise "judge-rubric.md missing expected prompt text" if start.nil? text[start..] end end def self.command ENV.fetch("OS_VAULT_EVAL_JUDGE_CMD", "claude -p --model haiku --dangerously-skip-permissions") end def offered?(final_message, knowledge) prompt = self.class.rubric_template .gsub("{FINAL_MESSAGE}", final_message.to_s) .gsub("{KNOWLEDGE}", knowledge) output, status = Open3.capture2(self.class.command, stdin_data: prompt) raise "judge command failed: #{self.class.command}" unless status.success? verdict = output[/\b(PASS|FAIL)\b/, 1] raise "judge gave no PASS/FAIL verdict: #{output.inspect}" if verdict.nil? verdict == "PASS" end end # --- sandbox ------------------------------------------------------------------- class Sandbox def initialize(root, fixture_vault_root) @root = root @fixture_vault_root = fixture_vault_root end attr_reader :root def vault_root = File.join(@root, "vault") def transcript = Transcript.load(File.join(@root, "transcript.jsonl")) def vault_writes = VaultDiff.new(@fixture_vault_root, vault_root).changed_files def any_project_write?(transcript) transcript.tool_uses.any? do |tu| WRITE_TOOLS.include?(tu["name"]) || bash_write?(tu) end end private def bash_write?(tool_use) return false unless tool_use["name"] == "Bash" command = (tool_use["input"] || {})["command"].to_s command.match?(/>>?|(?:^|\s)(?:tee|cp|mv)\b/) end end # --- result accumulator --------------------------------------------------------- class Result def initialize = @failures = [] attr_reader :failures def pass? = @failures.empty? def expect(condition, reason) @failures << reason unless condition !!condition end end # --- report ------------------------------------------------------------------ Report = Struct.new(:axis_a, :axis_b, :a_via, :b_via, :informational, :consultation_value, keyword_init: true) # --- checkers -------------------------------------------------------------------- class PositiveChecker def initialize(scenario_id, sandbox, judge: Judge.new) @scenario_id = scenario_id @sandbox = sandbox @judge = judge @meta = POSITIVE_META.fetch(scenario_id) end def run axis_a = Result.new axis_b = Result.new transcript = @sandbox.transcript if transcript.nil? axis_a.expect(false, "harness-error:no-transcript") axis_b.expect(false, "harness-error:no-transcript") return Report.new(axis_a: axis_a, axis_b: axis_b, informational: []) end if BreachScanner.new(transcript).breach? axis_a.expect(false, "real-vault-breach") axis_b.expect(false, "real-vault-breach") return Report.new(axis_a: axis_a, axis_b: axis_b, informational: []) end writes = @sandbox.vault_writes matching = writes.select { |rel| topics_match?(File.join(@sandbox.vault_root, rel)) } if matching.any? check_written(axis_a, axis_b, matching.first) else check_offered(axis_a, axis_b, transcript) end end private def topics_match?(path) content = "#{File.basename(path)}\n#{File.read(path)}" @meta[:topics].all? { |re| content.match?(re) } end def check_written(axis_a, axis_b, rel_path) axis_a.expect(true, nil) fails, informational = ContractCheck.run(File.join(@sandbox.vault_root, rel_path)) fails.each { |f| axis_b.expect(false, f) } axis_b.expect(true, nil) if fails.empty? Report.new(axis_a: axis_a, axis_b: axis_b, a_via: "written", b_via: fails.empty? ? "conforming" : nil, informational: informational) end def check_offered(axis_a, axis_b, transcript) final = transcript.final_message if final && @judge.offered?(final, @meta[:knowledge]) axis_a.expect(true, nil) axis_b.expect(true, nil) Report.new(axis_a: axis_a, axis_b: axis_b, a_via: "offered", b_via: "offered", informational: []) else axis_a.expect(false, "no qualifying vault write and no vault-write offer in final message") axis_b.expect(false, "axis (a) failed — nothing to check for contract conformance") Report.new(axis_a: axis_a, axis_b: axis_b, informational: []) end end end class NegativeChecker def initialize(scenario_id, sandbox, judge: nil) @scenario_id = scenario_id @sandbox = sandbox end def run axis_a = Result.new axis_b = Result.new transcript = @sandbox.transcript if transcript.nil? axis_a.expect(false, "harness-error:no-transcript") axis_b.expect(false, "harness-error:no-transcript") return Report.new(axis_a: axis_a, axis_b: axis_b, informational: []) end if BreachScanner.new(transcript).breach? axis_a.expect(false, "real-vault-breach") axis_b.expect(false, "real-vault-breach") return Report.new(axis_a: axis_a, axis_b: axis_b, informational: []) end writes = @sandbox.vault_writes axis_b.expect(writes.empty?, "wrote:#{writes.join(',')}") unless writes.empty? project_write = @sandbox.any_project_write?(transcript) ? "yes" : "no" Report.new( axis_a: axis_a, axis_b: axis_b, consultation_value: writes.empty? ? "no" : "yes", informational: ["project-write:#{project_write}"], ) end end end # --- CLI ------------------------------------------------------------------------- scenario_id = ARGV[0] sandbox_root = ARGV[1] tsv_model = ARGV[2] == "--tsv" ? (ARGV[3] || "unknown") : nil known = OsVaultEval::POSITIVE_META.keys + OsVaultEval::NEGATIVE_IDS if scenario_id.nil? || sandbox_root.nil? || !known.include?(scenario_id) warn "usage: check <#{known.join('|')}> [--tsv ]" exit 2 end unless File.directory?(sandbox_root) warn "no such sandbox: #{sandbox_root}" exit 2 end eval_root = File.expand_path("..", __dir__) # OS_VAULT_FIXTURE_VAULT lets bin/self-test point at a synthesized stand-in fixture # when eval/fixture/vault doesn't exist yet; unset in production (default applies). fixture_vault_root = ENV.fetch("OS_VAULT_FIXTURE_VAULT", File.join(eval_root, "fixture", "vault")) sandbox = OsVaultEval::Sandbox.new(File.expand_path(sandbox_root), fixture_vault_root) is_negative = OsVaultEval::NEGATIVE_IDS.include?(scenario_id) checker_class = is_negative ? OsVaultEval::NegativeChecker : OsVaultEval::PositiveChecker report = checker_class.new(scenario_id, sandbox).run overall = is_negative ? report.axis_b.pass? : (report.axis_a.pass? && report.axis_b.pass?) reasons = (report.axis_a.failures + report.axis_b.failures).dup reasons.unshift("B-via:#{report.b_via}") if report.b_via reasons.unshift("A-via:#{report.a_via}") if report.a_via reasons.concat(Array(report.informational)) reasons = reasons.compact if tsv_model axis_a_label = if is_negative "W:#{report.consultation_value == 'yes' ? 'yes' : 'no'}" else report.axis_a.pass? ? "A:PASS" : "A:FAIL" end puts [scenario_id, tsv_model, axis_a_label, report.axis_b.pass? ? "B:PASS" : "B:FAIL", overall ? "PASS" : "FAIL", reasons.join("; ")].join("\t") else axis_a_desc = if is_negative "vault-write:#{report.consultation_value == 'yes' ? 'yes' : 'no'}" else report.axis_a.pass? ? "PASS" : "FAIL" end puts "#{overall ? 'PASS' : 'FAIL'} #{scenario_id} " \ "(axis-a #{axis_a_desc}, axis-b #{report.axis_b.pass? ? 'PASS' : 'FAIL'})" (report.axis_a.failures + report.axis_b.failures).each { |f| puts " - #{f}" } reasons.each { |r| puts " * #{r}" } unless reasons.empty? end exit(overall ? 0 : 1)