kairos-chain 3.90.0 → 3.91.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1f38307df012164fc2070ff3a5a7489f651c678e4bcb75f4d93ef2ba65dec6d
4
- data.tar.gz: 11f40f3abe1a502600d451ffb4e6fe757f1cd351fa7f9af7c4c09fe0669f776c
3
+ metadata.gz: 8edb08c26be7f068db03e377e1f9d48ba67135b233dfa16f0fab9e7126364bca
4
+ data.tar.gz: 81557a33e8214065bf3e692948b58e3655e0758f5b95e13ae19c9bcc67c4cf17
5
5
  SHA512:
6
- metadata.gz: 5886c559503ae2223da85857a13e8fa2b365271f5988c3a24b77675ce56215ca481fb581d60f82f523dfe9a1cce4fb81e8b5cfc70f81fb68beec4142183c4450
7
- data.tar.gz: de3e9624a91ef9bbc9eaf3712cf64e6367f8b13523c1f3ace774cd31d0c7be4aa5560d60d7f8bfda9197802165dea0ab8a5dd7c74cb2bf666f4c903b26603249
6
+ metadata.gz: a6fbd7b1e9a1a347f941d292c6b0b55c8801b8ca609503281721d175a7567f27e2b20791ded6b2404eefbc3c6d7d8e84b16f3136fa0faffdad39d3bed574b044
7
+ data.tar.gz: fcff23454d07a3720ddb982a421137b292e9844228276e445181062ec64ac06025bf4cc4d0afe6c5be4202d889f1b8ddacf10eaa3859cc87ee2d998e627789d4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,60 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.91.0] - 2026-10-07
8
+
9
+ ### Added — agent: a shadow judge beside the operator (approval delegation, phase 1)
10
+
11
+ At the points the new delegation table covers, a fresh model answers the
12
+ operator's question beside the operator, and the two answers are compared
13
+ later. **In this phase the judge's answer changes nothing**: every point still
14
+ waits for you.
15
+
16
+ - **The delegation table** (`lib/agent/delegation_table.yml`) names two
17
+ points: plan approval in a manual session (`plan_proposed`) and the
18
+ scheduled checkpoint (`cycle_checkpoint`). Every row is `shadow`; the loader
19
+ refuses a table with any other mode or with an `mlr` approver. Like the
20
+ act-route table it is in force only by your terminal ruling, which also pins
21
+ the instruction mode the judge reads; editing that mode takes the table out
22
+ of force until you rule again:
23
+
24
+ ruby .kairos/skillsets/agent/bin/agent_rule.rb activate --table delegation
25
+
26
+ With no ruling, behaviour is as before.
27
+ - **Which points are judged**: only when the act route classifies every step of
28
+ the plan, no step is marked for a person, nothing touches L0, and the goal is
29
+ unchanged since the run started. Rows and precedents are chosen only by
30
+ signals the driver observes from the plan's structure — never by the plan's
31
+ summary wording or its own risk labels.
32
+ - **The judge** is a separate process (`bin/agent_shadow_judge.rb`) started
33
+ once the session has stopped at the point; nothing waits for it. It asks
34
+ `claude-opus-5-5` at effort `xhigh` through `llm_call` (`claude -p`,
35
+ sandboxed, no fallback provider) with the plan, the driver's signals, the
36
+ goal, your instruction mode and up to five of your earlier terminal answers
37
+ at the same kind of stop. Anything but a clear answer from that model counts
38
+ as "hand back to the operator".
39
+ - **Sealed, then shown**: the verdict goes on the chain as a salted commitment
40
+ (`agent_shadow_seal`) and is shown only after your answer at that point has
41
+ committed — in that `agent_step` response and in `agent_status` under
42
+ `shadow`. Before then `agent_status` says only `judging`, `sealed` or
43
+ `failed`. The verdict file sits in the session directory; reading it before
44
+ you answer defeats the comparison.
45
+ - **Counting**: `agent_rule.rb shadow` counts agreement only for answers you
46
+ typed at the terminal whose seal came first, approve and non-approve apart.
47
+ - Each judged point is one Opus call at `xhigh`.
48
+
49
+ ### Changed — agent: a run with a failed act is not the scheduled checkpoint
50
+
51
+ An autonomous run now stops as `cycle_checkpoint` only when every act of the
52
+ run succeeded — including an act resumed after a risk pause. A run in which an
53
+ act failed without an act error (a step failed or was denied, the guard failed
54
+ it) stops as `act_failed`, as a failed manual-mode act already did.
55
+
56
+ ### Fixed — agent: a torn read of the chain no longer empties the act-route table
57
+
58
+ The table loaders retry a read that overlaps a chain append, as the answer
59
+ reader already did.
60
+
7
61
  ## [3.90.0] - 2026-10-07
8
62
 
9
63
  ### Added — agent: every answer is a ruling on the chain, and an answer typed at a terminal binds
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.90.0"
2
+ VERSION = "3.91.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -2,18 +2,23 @@
2
2
  # frozen_string_literal: true
3
3
  #
4
4
  # The operator's terminal rulings for the agent (design v0.3, INV-A2 / INV-D8):
5
- # the act-route allow-list, and answers at a stopped session.
5
+ # the act-route allow-list, the delegation table, and answers at a stopped
6
+ # session.
6
7
  #
7
8
  # ruby .kairos/skillsets/agent/bin/agent_rule.rb activate # rule the current table into force
8
9
  # ruby .kairos/skillsets/agent/bin/agent_rule.rb withdraw # take it out of force
9
10
  # ruby .kairos/skillsets/agent/bin/agent_rule.rb status # show what is in force (records nothing)
10
11
  # ruby .kairos/skillsets/agent/bin/agent_rule.rb answer SESSION_ID
11
12
  # # answer a stopped session; the MCP answer there must then match
13
+ # ruby .kairos/skillsets/agent/bin/agent_rule.rb shadow # count the shadow judge's agreement
14
+ # # with your answers (records nothing)
12
15
  #
13
- # Options: --data-dir DIR the instance data dir whose chain is written
16
+ # Options: --table NAME activate / withdraw: act_classification (default)
17
+ # or delegation
18
+ # --data-dir DIR the instance data dir whose chain is written
14
19
  # (default: the .kairos this script is installed in)
15
- # --project-dir DIR answer only: the directory the MCP server runs in,
16
- # whose .kairos/storage holds its agent sessions
20
+ # --project-dir DIR answer / shadow: the directory the MCP server runs
21
+ # in, whose .kairos/storage holds its agent sessions
17
22
  # (default: the directory holding the data dir).
18
23
  # Needed when the server runs with --data-dir
19
24
  # pointing at another project's .kairos.
@@ -28,11 +33,16 @@
28
33
 
29
34
  require 'json'
30
35
 
31
- action = ARGV.find { |a| %w[activate withdraw status answer].include?(a) }
32
- abort 'usage: agent_rule.rb activate|withdraw|status|answer SESSION_ID [--data-dir DIR]' unless action
36
+ action = ARGV.find { |a| %w[activate withdraw status answer shadow].include?(a) }
37
+ abort 'usage: agent_rule.rb activate|withdraw [--table act_classification|delegation]|status|answer SESSION_ID|shadow ' \
38
+ '[--data-dir DIR] [--project-dir DIR]' unless action
33
39
  session_id = action == 'answer' ? ARGV[ARGV.index('answer') + 1].to_s : nil
34
40
  abort 'usage: agent_rule.rb answer SESSION_ID [--data-dir DIR]' if session_id&.then { |s| s.empty? || s.start_with?('-') }
35
41
 
42
+ tidx = ARGV.index('--table')
43
+ table = tidx ? ARGV[tidx + 1].to_s : 'act_classification'
44
+ abort "--table must be act_classification or delegation, not #{table.inspect}" unless %w[act_classification delegation].include?(table)
45
+
36
46
  idx = ARGV.index('--data-dir')
37
47
  data_dir = idx ? File.expand_path(ARGV[idx + 1].to_s) : File.expand_path('../../..', __dir__)
38
48
  abort "no data dir at #{data_dir}" unless File.directory?(data_dir)
@@ -50,15 +60,18 @@ lib = File.expand_path('../lib', __dir__)
50
60
  require File.join(lib, 'agent', 'admission')
51
61
  require File.join(lib, 'agent', 'mandate_adapter')
52
62
  require File.join(lib, 'agent', 'act_classification')
63
+ require File.join(lib, 'agent', 'answer_ruling')
64
+ require File.join(lib, 'agent', 'delegation')
65
+ require File.join(lib, 'agent', 'shadow_judge')
53
66
  ac = KairosMcp::SkillSets::Agent::ActClassification
67
+ dl = KairosMcp::SkillSets::Agent::Delegation
54
68
  path = ac::BASE_PATH
55
69
 
56
70
  puts "Data dir: #{data_dir}"
57
- if action == 'answer'
71
+ if %w[answer shadow].include?(action)
58
72
  require 'kairos_mcp/invocation_context'
59
73
  require File.join(lib, 'agent', 'session')
60
74
  require File.join(lib, 'agent', 'advance_gate')
61
- require File.join(lib, 'agent', 'answer_ruling')
62
75
  # The server keeps agent sessions under <its working directory>/.kairos/storage
63
76
  # (Session.storage_path without Autonomos loaded), and its chain under its
64
77
  # data dir. The two can differ; the answer is read from the first and
@@ -68,12 +81,14 @@ if action == 'answer'
68
81
  project_dir = pidx ? File.expand_path(ARGV[pidx + 1].to_s) : File.dirname(data_dir)
69
82
  abort "no project dir at #{project_dir}" unless File.directory?(project_dir)
70
83
  Dir.chdir(project_dir)
71
- session = KairosMcp::SkillSets::Agent::Session.load(session_id)
72
- unless session
73
- abort "no agent session #{session_id} under #{File.join(Dir.pwd, '.kairos', 'storage', 'agent_sessions')} " \
74
- '(if the MCP server runs in another directory, pass --project-dir)'
75
- end
76
84
  puts "Sessions from: #{project_dir}"
85
+ if action == 'answer'
86
+ session = KairosMcp::SkillSets::Agent::Session.load(session_id)
87
+ unless session
88
+ abort "no agent session #{session_id} under #{File.join(Dir.pwd, '.kairos', 'storage', 'agent_sessions')} " \
89
+ '(if the MCP server runs in another directory, pass --project-dir)'
90
+ end
91
+ end
77
92
  end
78
93
  if action == 'status'
79
94
  eff = ac.load_effective(path: path)
@@ -82,6 +97,31 @@ if action == 'status'
82
97
  puts " ruled sha256: #{eff['ruled_sha256'] || '(none)'}"
83
98
  puts " detail: #{eff['detail']}" if eff['detail']
84
99
  puts " tools in force: #{eff['tools'].keys.sort.join(', ')}" unless eff['tools'].empty?
100
+ deff = dl.load_effective
101
+ puts "Table #{dl::TABLE_ID}: #{deff['status']}"
102
+ puts " file sha256: #{deff['sha256']}"
103
+ puts " detail: #{deff['detail']}" if deff['detail']
104
+ deff['rows'].each { |r| puts " row #{r['id']} at #{r['point']} (#{r['mode']})" }
105
+ deff['reads'].each { |p| puts " judge reads #{p['name']}: #{p['path']} (sha256 #{p['sha256']})" }
106
+ exit 0
107
+ end
108
+ if action == 'shadow'
109
+ ar = KairosMcp::SkillSets::Agent::AnswerRuling
110
+ sj = KairosMcp::SkillSets::Agent::ShadowJudge
111
+ records, error = ar.chain_records
112
+ abort "The chain could not be read (#{error}); nothing to count." if error
113
+ sessions = File.expand_path(File.join('.kairos', 'storage', 'agent_sessions'))
114
+ verdict_dir = lambda do |sid, anchor|
115
+ sid.to_s.match?(/\A[\w-]+\z/) ? sj.anchor_dir(File.join(sessions, sid.to_s), anchor) : File::NULL
116
+ end
117
+ sum = sj.agreement(records, verdict_dir: verdict_dir)
118
+ yes = sum['operator_approved']
119
+ no = sum['operator_did_not_approve']
120
+ puts "Pairs that count as evidence (your terminal answer, sealed before it): #{sum['pairs']}"
121
+ puts " you approved: #{yes['n']}; the judge also approved #{yes['judge_agreed']}"
122
+ puts " you did not approve: #{no['n']}; the judge gave the same answer #{no['judge_agreed']}, approved #{no['judge_approved']}"
123
+ puts " the judge approved #{sum['judge_approved']} of #{sum['pairs']}"
124
+ puts "Not counted: #{sum['not_counted'].empty? ? '(none)' : sum['not_counted'].map { |k, v| "#{k} #{v}" }.join(', ')}"
85
125
  exit 0
86
126
  end
87
127
 
@@ -97,6 +137,8 @@ begin
97
137
  session: session, gate: KairosMcp::SkillSets::Agent::AdvanceGate.new(session.guard_dir),
98
138
  tty_in: tty, tty_out: tty, reload: -> { KairosMcp::SkillSets::Agent::Session.load(session_id) }
99
139
  )
140
+ elsif table == 'delegation'
141
+ dl.interactive_rule(action: action, tty_in: tty, tty_out: tty)
100
142
  else
101
143
  ac.interactive_rule(action: action, tty_in: tty, tty_out: tty, path: path)
102
144
  end
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # The shadow judge's process (design v0.3, INV-D6 / INV-D7). Started detached
5
+ # by the driver after an advance commits, once per anchor, with the packet the
6
+ # driver wrote. It reads the files the packet pins, asks a fresh model the
7
+ # operator's question, writes the verdict beside the packet and appends a
8
+ # salted commitment to it on the chain.
9
+ #
10
+ # It loads no agent tool and never opens the session's advance lock: nothing
11
+ # waits for it, and nothing it does can reach the operator's answer.
12
+ #
13
+ # The model is reached through llm_call (design v0.3 §4), loaded here as a
14
+ # tool object rather than through a registry: provider claude_code (claude -p)
15
+ # with sandbox_mode, the requested model and effort. llm_call falls back to
16
+ # another provider only from a provider other than claude_code, so the judge
17
+ # has none. Its timeout is llm_client's configured one (600 s as shipped).
18
+ #
19
+ # argv: <packet_path>
20
+ # env: KAIROS_SERVER_LIB lib dir to load kairos_mcp from
21
+ # KAIROS_DATA_DIR the server's data dir (the chain written)
22
+ # KAIROS_PROJECT_ROOT the server's working directory
23
+
24
+ require 'json'
25
+
26
+ packet_path = ARGV[0] or abort 'usage: agent_shadow_judge.rb <packet_path>'
27
+
28
+ begin
29
+ Process.setsid
30
+ rescue StandardError
31
+ # Already a group leader (spawned with pgroup): acceptable.
32
+ end
33
+
34
+ begin
35
+ Dir.chdir(ENV['KAIROS_PROJECT_ROOT']) if ENV['KAIROS_PROJECT_ROOT'] && Dir.exist?(ENV['KAIROS_PROJECT_ROOT'])
36
+ $LOAD_PATH.unshift(ENV['KAIROS_SERVER_LIB']) if ENV['KAIROS_SERVER_LIB']
37
+ require 'kairos_mcp'
38
+ KairosMcp.data_dir = ENV['KAIROS_DATA_DIR'] if ENV['KAIROS_DATA_DIR'] && !ENV['KAIROS_DATA_DIR'].empty?
39
+ require 'kairos_mcp/kairos_chain/chain'
40
+
41
+ lib = File.expand_path('../lib', __dir__)
42
+ require File.join(lib, 'agent', 'answer_ruling')
43
+ require File.join(lib, 'agent', 'delegation')
44
+ require File.join(lib, 'agent', 'shadow_judge')
45
+ require 'kairos_mcp/tools/base_tool'
46
+ # agent depends on llm_client; both are installed side by side.
47
+ require File.expand_path('../../llm_client/tools/llm_call', __dir__)
48
+ rescue ScriptError, StandardError => e
49
+ dir = File.dirname(packet_path)
50
+ File.write(File.join(dir, 'error.json'),
51
+ JSON.generate({ 'sealed' => false, 'error' => "bootstrap: #{e.class}: #{e.message[0, 200]}" }))
52
+ exit 1
53
+ end
54
+
55
+ llm = lambda do |system:, user:, model:, effort:, provider:|
56
+ out = KairosMcp::SkillSets::LlmClient::Tools::LlmCall.new(nil).call(
57
+ 'messages' => [{ 'role' => 'user', 'content' => user }], 'system' => system, 'model' => model,
58
+ 'effort' => effort, 'sandbox_mode' => true, 'provider_override' => provider
59
+ )
60
+ payload = JSON.parse(Array(out).first[:text])
61
+ raise "llm_call #{payload.dig('error', 'type')}: #{payload.dig('error', 'message')}" unless payload['status'] == 'ok'
62
+
63
+ payload['response'].merge('provider' => payload['provider'])
64
+ end
65
+
66
+ result = KairosMcp::SkillSets::Agent::ShadowJudge.judge_and_seal(
67
+ packet_path, llm: llm, chain: KairosMcp::KairosChain::Chain.new
68
+ )
69
+ warn JSON.generate(result)
70
+ exit(result['sealed'] ? 0 : 1)
@@ -92,16 +92,30 @@ module KairosMcp
92
92
  end
93
93
 
94
94
  # [rulings, nil] from the instance chain, or [[], reason] when the
95
- # chain cannot be read (which empties the table).
95
+ # chain cannot be read (which empties the table). The ledger is
96
+ # rewritten in place on every append, and the shadow judge appends
97
+ # from its own process at any moment, so a read is retried before a
98
+ # torn one empties the table (as AnswerRuling.chain_records).
99
+ READ_ATTEMPTS = 3
100
+
96
101
  def chain_rulings
97
102
  return ActClassification.rulings_source.call if ActClassification.rulings_source
98
103
 
99
104
  require 'kairos_mcp/kairos_chain/chain'
100
- chain = ::KairosMcp::KairosChain::Chain.new
101
- state = chain.load_state
102
- return [[], "chain #{state}"] unless %i[readable absent].include?(state)
103
-
104
- [rulings_from_blocks(chain.chain), nil]
105
+ reason = nil
106
+ READ_ATTEMPTS.times do |i|
107
+ sleep(0.05 * i) if i.positive?
108
+ begin
109
+ chain = ::KairosMcp::KairosChain::Chain.new
110
+ state = chain.load_state
111
+ return [rulings_from_blocks(chain.chain), nil] if %i[readable absent].include?(state)
112
+
113
+ reason = "chain #{state}"
114
+ rescue StandardError => e
115
+ reason = "chain unreadable: #{e.class}: #{e.message[0, 120]}"
116
+ end
117
+ end
118
+ [[], reason]
105
119
  rescue StandardError, ScriptError => e
106
120
  [[], "chain unreadable: #{e.class}: #{e.message[0, 120]}"]
107
121
  end
@@ -35,6 +35,9 @@ module KairosMcp
35
35
  module AnswerRuling
36
36
  KIND = 'agent_answer'
37
37
  ATTESTATION_KIND = 'agent_answer_attestation'
38
+ # The shadow judge's sealed verdict at a point (ShadowJudge), read
39
+ # beside the answers it is counted against.
40
+ SEAL_KIND = 'agent_shadow_seal'
38
41
  TEXTS_FILE = 'answer_texts.jsonl'
39
42
 
40
43
  # Why a session is waiting. Written with the state change that stops it
@@ -68,14 +71,15 @@ module KairosMcp
68
71
  task.nil? ? nil : Digest::SHA256.hexdigest(JSON.generate(task))
69
72
  end
70
73
 
71
- # Answer records in chain order, each with the index of its block.
74
+ # Answer and seal records in chain order, each with the index of its
75
+ # block.
72
76
  def records_from_blocks(blocks)
73
77
  Array(blocks).flat_map do |block|
74
78
  data = block.respond_to?(:data) ? block.data : (block['data'] || block[:data])
75
79
  index = block.respond_to?(:index) ? block.index : (block['index'] || block[:index])
76
80
  Array(data).filter_map do |entry|
77
81
  rec = entry.is_a?(String) ? (JSON.parse(entry) rescue nil) : entry
78
- next unless rec.is_a?(Hash) && [KIND, ATTESTATION_KIND].include?(rec['kind'])
82
+ next unless rec.is_a?(Hash) && [KIND, ATTESTATION_KIND, SEAL_KIND].include?(rec['kind'])
79
83
 
80
84
  rec.merge('block' => index)
81
85
  end
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require 'digest'
6
+ require 'time'
7
+
8
+ module KairosMcp
9
+ module SkillSets
10
+ module Agent
11
+ # The delegation table (design v0.3, INV-A2 / INV-D2 / INV-D3).
12
+ #
13
+ # Which approval points a delegate judges beside the operator. Phase 1
14
+ # has one approver kind (judge) and one mode (shadow), and the loader
15
+ # refuses a table that names anything else, so with or without a table
16
+ # every point returns to the operator.
17
+ #
18
+ # In force by the rule the classification table follows: the latest
19
+ # attested agent_table_ruling for table 'delegation' names this file's
20
+ # sha256. That ruling also pins what the judge reads (judge_reads) by
21
+ # path and sha256, so the material the judge decides by is material the
22
+ # operator ruled on (INV-D1); a changed file takes the table out of
23
+ # force until the operator rules again.
24
+ module Delegation
25
+ TABLE_ID = 'delegation'
26
+ BASE_PATH = File.expand_path('delegation_table.yml', __dir__)
27
+
28
+ # The two points a row may name (design v0.3 §1): plan approval in a
29
+ # manual session, and the scheduled end-of-cycle checkpoint.
30
+ POINTS = %w[plan_proposed cycle_checkpoint].freeze
31
+
32
+ # The driver's signals that it computes from the plan's structure
33
+ # (steps, tools, location arguments). The other two are the plan's own
34
+ # claims: design_scope is read from DECIDE's summary, and high_risk
35
+ # from a risk label the plan may raise (INV-P: declared, never
36
+ # observed). Rows are matched, and precedents chosen, by these alone,
37
+ # so the summary's wording and the plan's labels choose neither
38
+ # (INV-D1). The structure is still the plan's own: a plan can add a
39
+ # signal (a sixth step, a read under lib/). For rows that only adds to
40
+ # the floor (INV-D3) — it can keep a point from being judged, never
41
+ # make one judged; for precedents it selects the rulings that carried
42
+ # the same signal.
43
+ OBSERVED_SIGNALS = %w[many_steps l0_change core_files multi_file state_mutation].freeze
44
+ MATCHABLE_SIGNALS = OBSERVED_SIGNALS
45
+
46
+ # What a table may give the judge to read, by symbolic name.
47
+ READABLE = %w[instruction_mode].freeze
48
+
49
+ TOP_KEYS = %w[version judge_reads precedents rows].freeze
50
+ ROW_KEYS = %w[id point signals_absent approver mode].freeze
51
+ MAX_PRECEDENTS = 10
52
+
53
+ # The judge (design v0.3 §4, operator decision §6.2). No fallback
54
+ # provider: a judge that cannot be reached as this does not answer.
55
+ JUDGE = { 'provider' => 'claude_code', 'model' => 'claude-opus-5-5', 'effort' => 'xhigh' }.freeze
56
+
57
+ class << self
58
+ # Test seam: a callable name -> [absolute_path, nil] | [nil, reason].
59
+ # nil in production (resolve_read).
60
+ attr_accessor :read_resolver
61
+ end
62
+
63
+ module_function
64
+
65
+ # [table, nil] or [nil, error]. table: { 'judge_reads' => [names],
66
+ # 'precedents_max' => Integer, 'rows' => [row] }.
67
+ def parse_table(raw)
68
+ doc = YAML.safe_load(raw)
69
+ return [nil, 'not a mapping'] unless doc.is_a?(Hash)
70
+
71
+ extra = doc.keys.map(&:to_s) - TOP_KEYS
72
+ return [nil, "unknown keys: #{extra.join(', ')}"] unless extra.empty?
73
+ return [nil, 'version must be 1'] unless doc.fetch('version', 1) == 1
74
+
75
+ reads = doc['judge_reads'] || []
76
+ return [nil, 'judge_reads must be a list of names'] unless reads.is_a?(Array) && reads.all?(String)
77
+
78
+ bad = reads - READABLE
79
+ return [nil, "judge_reads: #{bad.join(', ')} cannot be read (readable: #{READABLE.join(', ')})"] unless bad.empty?
80
+
81
+ prec = doc['precedents'] || {}
82
+ return [nil, 'precedents must be a mapping with max'] unless prec.is_a?(Hash) && (prec.keys.map(&:to_s) - %w[max]).empty?
83
+
84
+ max = prec.fetch('max', 0)
85
+ unless max.is_a?(Integer) && max.between?(0, MAX_PRECEDENTS)
86
+ return [nil, "precedents.max must be an integer from 0 to #{MAX_PRECEDENTS}"]
87
+ end
88
+
89
+ rows = doc['rows']
90
+ return [nil, 'rows must be a list'] unless rows.is_a?(Array)
91
+
92
+ ids = []
93
+ parsed = rows.each_with_index.map do |row, i|
94
+ label = "row #{i + 1}"
95
+ return [nil, "#{label}: not a mapping"] unless row.is_a?(Hash)
96
+
97
+ extra = row.keys.map(&:to_s) - ROW_KEYS
98
+ return [nil, "#{label}: unknown keys: #{extra.join(', ')}"] unless extra.empty?
99
+
100
+ id = row['id']
101
+ return [nil, "#{label}: id must be a name such as cycle_checkpoint"] unless id.is_a?(String) && id.match?(/\A[a-z0-9_]+\z/)
102
+ return [nil, "row #{id}: the id is used twice"] if ids.include?(id)
103
+
104
+ ids << id
105
+ return [nil, "row #{id}: point must be one of #{POINTS.join(', ')}"] unless POINTS.include?(row['point'])
106
+ if row['approver'] == 'mlr'
107
+ return [nil, "row #{id}: approver mlr is not available in phase 1 (its verdict is a vote ratio, " \
108
+ 'which the project has declared non-conclusive)']
109
+ end
110
+ return [nil, "row #{id}: approver must be judge"] unless row['approver'] == 'judge'
111
+ unless row['mode'] == 'shadow'
112
+ return [nil, "row #{id}: mode #{row['mode'].inspect} is refused; phase 1 runs shadow rows only"]
113
+ end
114
+
115
+ absent = row['signals_absent'] || []
116
+ return [nil, "row #{id}: signals_absent must be a list"] unless absent.is_a?(Array) && absent.all?(String)
117
+
118
+ bad = absent - MATCHABLE_SIGNALS
119
+ unless bad.empty?
120
+ return [nil, "row #{id}: signals_absent may name only #{MATCHABLE_SIGNALS.join(', ')} (not #{bad.join(', ')})"]
121
+ end
122
+
123
+ { 'id' => id, 'point' => row['point'], 'signals_absent' => absent, 'approver' => 'judge', 'mode' => 'shadow' }
124
+ end
125
+
126
+ [{ 'judge_reads' => reads, 'precedents_max' => max, 'rows' => parsed }, nil]
127
+ rescue Psych::Exception => e
128
+ [nil, "YAML: #{e.message[0, 120]}"]
129
+ end
130
+
131
+ # [absolute_path, nil] or [nil, reason]: the file the server reads for
132
+ # a readable name. instruction_mode follows the server's own resolution
133
+ # of the active mode (Protocol#load_instructions).
134
+ def resolve_read(name)
135
+ return Delegation.read_resolver.call(name) if Delegation.read_resolver
136
+ return [nil, "#{name} cannot be read"] unless name == 'instruction_mode'
137
+
138
+ require 'kairos_mcp/skills_config'
139
+ mode = ::KairosMcp::SkillsConfig.load['instructions_mode'] || 'tutorial'
140
+ path = case mode
141
+ when 'developer' then ::KairosMcp.md_path
142
+ when 'user' then ::KairosMcp.quickguide_path
143
+ when 'tutorial' then ::KairosMcp.tutorial_path
144
+ when 'none' then nil
145
+ else File.join(::KairosMcp.skills_dir, "#{mode}.md")
146
+ end
147
+ return [nil, 'no instruction mode is active'] unless path
148
+ return [nil, "#{path} is not readable"] unless File.file?(path)
149
+
150
+ [File.expand_path(path), nil]
151
+ rescue StandardError, ScriptError => e
152
+ [nil, "#{e.class}: #{e.message[0, 120]}"]
153
+ end
154
+
155
+ # [[{ 'name', 'path', 'bytes', 'sha256' }], nil] or [nil, reason]. One
156
+ # read per file: the hash is of the bytes counted.
157
+ def read_pins(names)
158
+ pins = names.map do |name|
159
+ path, error = resolve_read(name)
160
+ return [nil, "#{name}: #{error}"] if error
161
+
162
+ raw = File.binread(path)
163
+ { 'name' => name, 'path' => path, 'bytes' => raw.bytesize, 'sha256' => Digest::SHA256.hexdigest(raw) }
164
+ end
165
+ [pins, nil]
166
+ rescue StandardError => e
167
+ [nil, "#{e.class}: #{e.message[0, 120]}"]
168
+ end
169
+
170
+ # Names whose path or bytes differ from the ruling's pins, or that one
171
+ # side has and the other does not.
172
+ def changed_reads(pins, ruled)
173
+ ruled = Array(ruled).select { |r| r.is_a?(Hash) }
174
+ names = (pins.map { |p| p['name'] } + ruled.map { |r| r['name'] }).uniq
175
+ names.reject do |n|
176
+ now = pins.find { |p| p['name'] == n }
177
+ then_ = ruled.find { |r| r['name'] == n }
178
+ now && then_ && now['path'] == then_['path'] && now['sha256'] == then_['sha256']
179
+ end
180
+ end
181
+
182
+ # The effective table: { 'status', 'sha256', 'rows', 'judge_reads',
183
+ # 'precedents_max', 'reads' (pins), 'detail' }. status is 'in_force'
184
+ # only when every condition holds; otherwise rows is empty.
185
+ def load_effective(path: BASE_PATH, rulings: nil, rulings_error: nil)
186
+ ac = ::KairosMcp::SkillSets::Agent::ActClassification
187
+ rulings, rulings_error = ac.chain_rulings if rulings.nil? && rulings_error.nil?
188
+ raw = begin
189
+ File.binread(path)
190
+ rescue StandardError => e
191
+ return empty('table_unreadable', nil, detail: "#{e.class}: #{e.message[0, 120]}")
192
+ end
193
+ sha = Digest::SHA256.hexdigest(raw)
194
+ return empty('chain_unreadable', sha, detail: rulings_error) if rulings_error
195
+
196
+ latest = Array(rulings).select { |r| r['table'] == TABLE_ID && r['attested'] == true }.last
197
+ return empty('no_ruling', sha) unless latest
198
+ return empty('withdrawn', sha) if latest['action'] == 'withdraw'
199
+ return empty('invalid_ruling', sha) unless latest['action'] == 'activate'
200
+ return empty('hash_mismatch', sha) unless latest['sha256'] == sha
201
+
202
+ table, error = parse_table(raw)
203
+ return empty('invalid', sha, detail: error) if error
204
+
205
+ pins, error = read_pins(table['judge_reads'])
206
+ return empty('reads_unresolvable', sha, detail: error) if error
207
+
208
+ changed = changed_reads(pins, latest['reads'])
209
+ unless changed.empty?
210
+ return empty('reads_changed', sha,
211
+ detail: "#{changed.join(', ')} changed since the ruling; the judge reads only what was ruled on")
212
+ end
213
+
214
+ table.merge('status' => 'in_force', 'sha256' => sha, 'reads' => pins, 'ruled_at' => latest['ruled_at'])
215
+ end
216
+
217
+ def empty(status, sha, detail: nil)
218
+ out = { 'status' => status, 'sha256' => sha, 'rows' => [], 'judge_reads' => [], 'precedents_max' => 0,
219
+ 'reads' => [] }
220
+ out['detail'] = detail if detail
221
+ out
222
+ end
223
+
224
+ # The first row, in file order, for this point whose absent signals
225
+ # are all absent. nil when none matches.
226
+ def match(effective, point, signals)
227
+ Array(effective['rows']).find do |row|
228
+ row['point'] == point && (row['signals_absent'] & Array(signals)).empty?
229
+ end
230
+ end
231
+
232
+ # ---- the terminal ruling (INV-A2 / INV-D8) ----
233
+
234
+ # As ActClassification.interactive_rule, for this table: shows the
235
+ # rows and what the judge will read, asks for the nonce, and records a
236
+ # ruling that names the table's sha256 and pins every read.
237
+ def interactive_rule(action:, tty_in:, tty_out:, path: BASE_PATH, chain: nil,
238
+ nonce_value: ::KairosMcp::SkillSets::Agent::ActClassification.nonce)
239
+ raise ArgumentError, "action must be activate or withdraw, not #{action.inspect}" unless %w[activate withdraw].include?(action)
240
+
241
+ sha = nil
242
+ pins = nil
243
+ if action == 'activate'
244
+ raw = File.binread(path)
245
+ sha = Digest::SHA256.hexdigest(raw)
246
+ table, error = parse_table(raw)
247
+ pins, error = read_pins(table['judge_reads']) unless error
248
+ if error
249
+ tty_out.puts "Refused: #{path} cannot be ruled into force (#{error})."
250
+ return { 'recorded' => false, 'reason' => 'invalid', 'detail' => error }
251
+ end
252
+ tty_out.puts "Table: #{TABLE_ID}"
253
+ tty_out.puts "File: #{path}"
254
+ tty_out.puts "sha256: #{sha}"
255
+ tty_out.puts "Phase 1: every row is shadow. A judge (#{JUDGE['model']}, effort #{JUDGE['effort']}, " \
256
+ "through #{JUDGE['provider']}) answers beside you at these points; its verdict is sealed " \
257
+ 'before your answer, shown after it, and changes nothing.'
258
+ table['rows'].each do |row|
259
+ absent = row['signals_absent'].empty? ? '' : " unless the driver sees #{row['signals_absent'].join(', ')}"
260
+ tty_out.puts " #{row['id'].ljust(22)} at #{row['point']}#{absent}"
261
+ end
262
+ tty_out.puts 'The judge reads (pinned by this ruling; a change takes the table out of force):'
263
+ pins.each { |p| tty_out.puts " #{p['name']}: #{p['path']} (#{p['bytes']} bytes, sha256 #{p['sha256']})" }
264
+ tty_out.puts "and up to #{table['precedents_max']} of your earlier terminal answers at the same stop " \
265
+ 'with the same signals.'
266
+ else
267
+ tty_out.puts "Withdraw table #{TABLE_ID}: no judge runs until a new ruling."
268
+ end
269
+ tty_out.puts
270
+ tty_out.print "Type #{nonce_value} to record this ruling (anything else cancels): "
271
+ tty_out.flush
272
+ unless tty_in.gets.to_s.strip == nonce_value
273
+ tty_out.puts 'Cancelled. Nothing was recorded.'
274
+ return { 'recorded' => false, 'reason' => 'nonce_mismatch' }
275
+ end
276
+
277
+ record = { 'kind' => ::KairosMcp::SkillSets::Agent::ActClassification::RULING_KIND, 'table' => TABLE_ID,
278
+ 'action' => action, 'sha256' => sha, 'attested' => true, 'attestation' => 'terminal_nonce',
279
+ 'ruled_at' => Time.now.utc.iso8601 }
280
+ record['reads'] = pins.map { |p| p.slice('name', 'path', 'sha256') } if pins
281
+ chain ||= begin
282
+ require 'kairos_mcp/kairos_chain/chain'
283
+ ::KairosMcp::KairosChain::Chain.new
284
+ end
285
+ block = chain.add_block([JSON.generate(record)])
286
+ tty_out.puts "Recorded as block ##{block.index}."
287
+ { 'recorded' => true, 'block_index' => block.index, 'record' => record }
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end