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 +4 -4
- data/CHANGELOG.md +54 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/bin/agent_rule.rb +55 -13
- data/templates/skillsets/agent/bin/agent_shadow_judge.rb +70 -0
- data/templates/skillsets/agent/lib/agent/act_classification.rb +20 -6
- data/templates/skillsets/agent/lib/agent/answer_ruling.rb +6 -2
- data/templates/skillsets/agent/lib/agent/delegation.rb +292 -0
- data/templates/skillsets/agent/lib/agent/delegation_table.yml +40 -0
- data/templates/skillsets/agent/lib/agent/shadow_judge.rb +508 -0
- data/templates/skillsets/agent/lib/agent.rb +2 -0
- data/templates/skillsets/agent/plugin/SKILL.md +32 -0
- data/templates/skillsets/agent/test/test_agent_shadow_judge.rb +1036 -0
- data/templates/skillsets/agent/tools/agent_status.rb +8 -0
- data/templates/skillsets/agent/tools/agent_step.rb +193 -7
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8edb08c26be7f068db03e377e1f9d48ba67135b233dfa16f0fab9e7126364bca
|
|
4
|
+
data.tar.gz: 81557a33e8214065bf3e692948b58e3655e0758f5b95e13ae19c9bcc67c4cf17
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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
|
|
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: --
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|