kairos-chain 3.89.0 → 3.90.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: 109c03e66d55828499533b9d00945390f97cfd8aa56376aa898c099a30218316
4
- data.tar.gz: 3013372fe35ae2bf4e3e5a4267fed64197e7480da32f2030664eb61766724f44
3
+ metadata.gz: b1f38307df012164fc2070ff3a5a7489f651c678e4bcb75f4d93ef2ba65dec6d
4
+ data.tar.gz: 11f40f3abe1a502600d451ffb4e6fe757f1cd351fa7f9af7c4c09fe0669f776c
5
5
  SHA512:
6
- metadata.gz: 824d9d93d7e2c22a991e116a2f8c66acf8d54ecb2469a541c2f51a0ae982b0018d2650f88931daa5cf6da2091bba5ed470e13334c7b341d09fdfa2a7f48c01f8
7
- data.tar.gz: bcc18d3c8123b54db6b934fc475f8517a7cd3dc2562a34d213c37140f08b45218d461c3685216f6577e9683de111f7ec70a07f9da27e5bfe947d0e740caffc5f
6
+ metadata.gz: 5886c559503ae2223da85857a13e8fa2b365271f5988c3a24b77675ce56215ca481fb581d60f82f523dfe9a1cce4fb81e8b5cfc70f81fb68beec4142183c4450
7
+ data.tar.gz: de3e9624a91ef9bbc9eaf3712cf64e6367f8b13523c1f3ace774cd31d0c7be4aa5560d60d7f8bfda9197802165dea0ab8a5dd7c74cb2bf666f4c903b26603249
data/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ 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.90.0] - 2026-10-07
8
+
9
+ ### Added — agent: every answer is a ruling on the chain, and an answer typed at a terminal binds
10
+
11
+ Until now the gate log kept which answer advanced a session, but not who gave
12
+ it, why, or what it was given to. **Every answer that advances an agent session
13
+ is now recorded on the chain as a ruling** (`agent_answer`) — through
14
+ `agent_step` (approve, revise, skip, stop, adjudicate) and `agent_stop` alike.
15
+
16
+ - **What a ruling holds**: the point it answered (state, cycle, and why the
17
+ session was waiting), the plan's SHA-256 and the driver's own signals when a
18
+ plan was the subject, the act-route table in force, the decision, and whether
19
+ it was attested. A replayed retry writes none. A ruling that fails to record
20
+ never blocks the answer; the response says so under `ruling`. Reasons
21
+ (`rationale`, new optional argument) and revise feedback stay off the chain;
22
+ the ruling carries their SHA-256.
23
+ - **Answer as yourself, at a terminal**:
24
+
25
+ ruby .kairos/skillsets/agent/bin/agent_rule.rb answer SESSION_ID
26
+
27
+ It shows why the session stopped and the whole plan (every argument, escaped,
28
+ and the file it is kept in), asks for your answer, an optional reason and a
29
+ nonce, and records the answer bound to that point and that plan. The answer
30
+ then sent through `agent_step` there must be the same one, or it is refused
31
+ (`answer_refused`); **a stop always goes through**. After a revise at the
32
+ terminal, send `revise` without feedback and your text is used. Pass
33
+ `--project-dir` when the MCP server runs in another directory than the
34
+ project holding its data dir. An answer sent only through MCP is recorded as
35
+ the caller's, unattested.
36
+ - **Every waiting session says why it stopped** (`stop` in every response and in
37
+ `agent_status`). `cycle_checkpoint` means only the scheduled end-of-cycle
38
+ stop (Gate 8, or a manual act that succeeded and left nothing over); failed
39
+ acts, set-aside steps, guard halts, adjudications, risk pauses and the rest
40
+ name themselves.
41
+ - **While the chain cannot be read, every answer but a stop is refused**,
42
+ because whether you answered at the terminal cannot be checked. On chain
43
+ stores other than the file ledger (sqlite, postgresql) the agent can only be
44
+ stopped.
45
+
46
+ No action is needed after upgrading. Approval delegation (the shadow judge) is
47
+ not part of this release; do not run the agent unattended.
48
+
7
49
  ## [3.89.0] - 2026-10-07
8
50
 
9
51
  ### Security — agent: the act route runs from an allow-list
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.89.0"
2
+ VERSION = "3.90.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -1,17 +1,24 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
  #
4
- # The operator's terminal ruling on the agent's act-route allow-list
5
- # (design v0.3, INV-A2 / INV-D8).
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.
6
6
  #
7
7
  # ruby .kairos/skillsets/agent/bin/agent_rule.rb activate # rule the current table into force
8
8
  # ruby .kairos/skillsets/agent/bin/agent_rule.rb withdraw # take it out of force
9
9
  # ruby .kairos/skillsets/agent/bin/agent_rule.rb status # show what is in force (records nothing)
10
+ # ruby .kairos/skillsets/agent/bin/agent_rule.rb answer SESSION_ID
11
+ # # answer a stopped session; the MCP answer there must then match
10
12
  #
11
- # Options: --data-dir DIR the instance data dir whose chain is written
12
- # (default: the .kairos this script is installed in)
13
+ # Options: --data-dir DIR the instance data dir whose chain is written
14
+ # (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
17
+ # (default: the directory holding the data dir).
18
+ # Needed when the server runs with --data-dir
19
+ # pointing at another project's .kairos.
13
20
  #
14
- # activate and withdraw read a nonce typed back at /dev/tty and refuse to run
21
+ # activate, withdraw and answer read a nonce typed back at /dev/tty and refuse to run
15
22
  # without a terminal. That keeps an MCP call from producing a ruling through
16
23
  # this path. It does not prove a person typed: any process that can start
17
24
  # programs can open a pseudo-terminal, read the nonce and type it back (shown
@@ -21,8 +28,10 @@
21
28
 
22
29
  require 'json'
23
30
 
24
- action = ARGV.find { |a| %w[activate withdraw status].include?(a) }
25
- abort 'usage: agent_rule.rb activate|withdraw|status [--data-dir DIR]' unless action
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
33
+ session_id = action == 'answer' ? ARGV[ARGV.index('answer') + 1].to_s : nil
34
+ abort 'usage: agent_rule.rb answer SESSION_ID [--data-dir DIR]' if session_id&.then { |s| s.empty? || s.start_with?('-') }
26
35
 
27
36
  idx = ARGV.index('--data-dir')
28
37
  data_dir = idx ? File.expand_path(ARGV[idx + 1].to_s) : File.expand_path('../../..', __dir__)
@@ -45,6 +54,27 @@ ac = KairosMcp::SkillSets::Agent::ActClassification
45
54
  path = ac::BASE_PATH
46
55
 
47
56
  puts "Data dir: #{data_dir}"
57
+ if action == 'answer'
58
+ require 'kairos_mcp/invocation_context'
59
+ require File.join(lib, 'agent', 'session')
60
+ require File.join(lib, 'agent', 'advance_gate')
61
+ require File.join(lib, 'agent', 'answer_ruling')
62
+ # The server keeps agent sessions under <its working directory>/.kairos/storage
63
+ # (Session.storage_path without Autonomos loaded), and its chain under its
64
+ # data dir. The two can differ; the answer is read from the first and
65
+ # recorded on the second, the chain the server reads.
66
+ pidx = ARGV.index('--project-dir')
67
+ abort 'usage: agent_rule.rb answer SESSION_ID --project-dir DIR' if pidx && ARGV[pidx + 1].to_s.then { |v| v.empty? || v.start_with?('-') }
68
+ project_dir = pidx ? File.expand_path(ARGV[pidx + 1].to_s) : File.dirname(data_dir)
69
+ abort "no project dir at #{project_dir}" unless File.directory?(project_dir)
70
+ 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
+ puts "Sessions from: #{project_dir}"
77
+ end
48
78
  if action == 'status'
49
79
  eff = ac.load_effective(path: path)
50
80
  puts "Table #{ac::TABLE_ID}: #{eff['status']}"
@@ -62,7 +92,14 @@ rescue StandardError => e
62
92
  end
63
93
 
64
94
  begin
65
- result = ac.interactive_rule(action: action, tty_in: tty, tty_out: tty, path: path)
95
+ result = if action == 'answer'
96
+ KairosMcp::SkillSets::Agent::AnswerRuling.interactive_answer(
97
+ session: session, gate: KairosMcp::SkillSets::Agent::AdvanceGate.new(session.guard_dir),
98
+ tty_in: tty, tty_out: tty, reload: -> { KairosMcp::SkillSets::Agent::Session.load(session_id) }
99
+ )
100
+ else
101
+ ac.interactive_rule(action: action, tty_in: tty, tty_out: tty, path: path)
102
+ end
66
103
  ensure
67
104
  tty.close
68
105
  end
@@ -0,0 +1,481 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+ require 'time'
6
+
7
+ module KairosMcp
8
+ module SkillSets
9
+ module Agent
10
+ # Every answer at an approval point is a ruling on the chain
11
+ # (design v0.3, INV-D4 / INV-D8).
12
+ #
13
+ # Two record kinds:
14
+ #
15
+ # agent_answer one per committed advance, written by the
16
+ # driver when the advance commits. It is the
17
+ # ruling: it exists only for a decision that
18
+ # was carried out, and a replayed retry never
19
+ # writes a second one (it never commits).
20
+ # agent_answer_attestation the operator's answer typed at the terminal
21
+ # (bin/agent_rule.rb answer), bound to the
22
+ # anchor it was given at. While it is the
23
+ # latest one at that anchor, an MCP answer
24
+ # there proceeds only if it is the same
25
+ # answer; a stop always proceeds (operator
26
+ # ruling 2026-10-07: stopping is never
27
+ # refused) and is recorded as overriding it.
28
+ #
29
+ # Texts (revise feedback, rationale) stay off the chain in the session
30
+ # directory; records carry their sha256.
31
+ #
32
+ # Attestation boundary (INV-D8): 'terminal_nonce' records the path the
33
+ # answer came by. Any party that can write the chain could write a block
34
+ # of the same shape; the constrained party in phase 1 is the agent.
35
+ module AnswerRuling
36
+ KIND = 'agent_answer'
37
+ ATTESTATION_KIND = 'agent_answer_attestation'
38
+ TEXTS_FILE = 'answer_texts.jsonl'
39
+
40
+ # Why a session is waiting. Written with the state change that stops it
41
+ # (Session#update_state / #stop_at); a stop without one reads as
42
+ # 'unspecified'. cycle_checkpoint is the scheduled end-of-cycle stop
43
+ # (Gate 8 in autonomous mode, the end of an act with nothing left over
44
+ # in manual mode) and nothing else.
45
+ STOP_KINDS = %w[
46
+ session_started cycle_observed plan_proposed cycle_checkpoint cycle_skipped adjudicated
47
+ awaiting_operator guard_halt human_cognition_halt act_failed phase_error
48
+ risk_exceeded goal_drift timeout llm_budget_exceeded l0_escalation
49
+ review_rejected review_max_retries terminated
50
+ ].freeze
51
+
52
+ # States at which the persisted plan is the subject of the answer.
53
+ PLAN_STATES = %w[proposed checkpoint paused_risk].freeze
54
+
55
+ class << self
56
+ # Test seam: a callable returning [records, error]. nil in production.
57
+ attr_accessor :records_source
58
+ end
59
+
60
+ module_function
61
+
62
+ def sha256(text)
63
+ text.nil? || text.to_s.empty? ? nil : Digest::SHA256.hexdigest(text.to_s)
64
+ end
65
+
66
+ def plan_sha256(decision)
67
+ task = decision.is_a?(Hash) ? decision['task_json'] : nil
68
+ task.nil? ? nil : Digest::SHA256.hexdigest(JSON.generate(task))
69
+ end
70
+
71
+ # Answer records in chain order, each with the index of its block.
72
+ def records_from_blocks(blocks)
73
+ Array(blocks).flat_map do |block|
74
+ data = block.respond_to?(:data) ? block.data : (block['data'] || block[:data])
75
+ index = block.respond_to?(:index) ? block.index : (block['index'] || block[:index])
76
+ Array(data).filter_map do |entry|
77
+ rec = entry.is_a?(String) ? (JSON.parse(entry) rescue nil) : entry
78
+ next unless rec.is_a?(Hash) && [KIND, ATTESTATION_KIND].include?(rec['kind'])
79
+
80
+ rec.merge('block' => index)
81
+ end
82
+ end
83
+ end
84
+
85
+ # [records, nil], or [[], reason] when the chain cannot be read. The
86
+ # ledger is rewritten in place on every append, so a read that races a
87
+ # writer can see a torn file (measured in review, about 1 in 10,000
88
+ # reads under load); a read is retried before it is believed.
89
+ READ_ATTEMPTS = 3
90
+
91
+ def chain_records
92
+ return AnswerRuling.records_source.call if AnswerRuling.records_source
93
+
94
+ require 'kairos_mcp/kairos_chain/chain'
95
+ reason = nil
96
+ READ_ATTEMPTS.times do |i|
97
+ sleep(0.05 * i) if i.positive?
98
+ begin
99
+ chain = ::KairosMcp::KairosChain::Chain.new
100
+ state = chain.load_state
101
+ return [records_from_blocks(chain.chain), nil] if %i[readable absent].include?(state)
102
+
103
+ reason = "chain #{state}"
104
+ rescue StandardError => e
105
+ reason = "chain unreadable: #{e.class}: #{e.message[0, 120]}"
106
+ end
107
+ end
108
+ [[], reason]
109
+ rescue StandardError, ScriptError => e
110
+ [[], "chain unreadable: #{e.class}: #{e.message[0, 120]}"]
111
+ end
112
+
113
+ # An earlier ruling at the same anchor of the same session: only a
114
+ # crash between the chain write and the gate commit leaves one. The
115
+ # later ruling names it, so a count takes one ruling per anchor.
116
+ def prior_ruling_at(records, session_id, anchor)
117
+ Array(records).select do |r|
118
+ r['kind'] == KIND && r['session_id'] == session_id && r['anchor'] == anchor
119
+ end.last
120
+ end
121
+
122
+ # The operator's latest attested answer at this anchor, or nil.
123
+ def attestation_at(records, session_id, anchor)
124
+ Array(records).select do |r|
125
+ r['kind'] == ATTESTATION_KIND && r['attested'] == true &&
126
+ r['session_id'] == session_id && r['anchor'] == anchor
127
+ end.last
128
+ end
129
+
130
+ # Whether an answer arriving through MCP may proceed where the operator
131
+ # answered at the terminal. Returns { 'ok' => true, 'feedback',
132
+ # 'attestation' | 'overrode' } or { 'ok' => false, 'reason',
133
+ # 'attestation' }. A revise without feedback takes the text typed at the
134
+ # terminal, if its sha256 is the one the attestation names.
135
+ # check_plan: false for a call that targets an anchor the session has
136
+ # already left; such a call can only replay, and the plan has moved on.
137
+ def reconcile(attestation, action:, resolution: nil, feedback: nil, plan_sha256: nil, texts: nil,
138
+ check_plan: true)
139
+ return { 'ok' => true, 'feedback' => feedback } unless attestation
140
+
141
+ if action == 'stop'
142
+ key = attestation['decision'] == 'stop' ? 'attestation' : 'overrode'
143
+ return { 'ok' => true, 'feedback' => feedback, key => attestation }
144
+ end
145
+ refuse = ->(why) { { 'ok' => false, 'reason' => why, 'attestation' => attestation } }
146
+ unless attestation['decision'] == action
147
+ return refuse.call("the operator answered #{attestation['decision']} at the terminal")
148
+ end
149
+ if action == 'adjudicate' && attestation['resolution'] != resolution
150
+ return refuse.call("the operator adjudicated #{attestation['resolution']} at the terminal")
151
+ end
152
+ if check_plan && attestation['plan_sha256'] && attestation['plan_sha256'] != plan_sha256
153
+ return refuse.call('the plan is not the one the operator answered at the terminal')
154
+ end
155
+ if action == 'revise'
156
+ want = attestation['feedback_sha256']
157
+ if feedback.to_s.empty?
158
+ text = Array(texts&.call).reverse.map { |t| t['feedback'] }.find { |f| want && sha256(f) == want }
159
+ return refuse.call('the feedback typed at the terminal is not available; send it with the answer') unless text
160
+
161
+ feedback = text
162
+ elsif sha256(feedback) != want
163
+ return refuse.call('the feedback differs from the one typed at the terminal')
164
+ end
165
+ end
166
+ { 'ok' => true, 'feedback' => feedback, 'attestation' => attestation }
167
+ end
168
+
169
+ # The ruling for a committed advance. point: { 'state', 'cycle', 'stop',
170
+ # and when a plan is the subject 'plan_sha256', 'signals', 'set_aside' }.
171
+ def ruling(session_id:, mandate_id:, anchor:, point:, action:, action_key:, tables:,
172
+ answer: {}, feedback: nil, rationale: nil)
173
+ att = answer['attestation']
174
+ rec = {
175
+ 'kind' => KIND, 'session_id' => session_id, 'mandate_id' => mandate_id,
176
+ 'anchor' => anchor, 'state' => point['state'], 'cycle' => point['cycle'],
177
+ 'stop' => point['stop'], 'decision' => action, 'decision_key' => action_key,
178
+ 'approver' => att ? 'operator' : 'caller',
179
+ 'attestation' => att ? 'terminal_nonce' : 'caller_unattested',
180
+ 'tables' => tables, 'answered_at' => Time.now.utc.iso8601
181
+ }
182
+ rec['attestation_block'] = att['block'] if att
183
+ rec['overrode_attestation_block'] = answer['overrode']['block'] if answer['overrode']
184
+ rec['attestation_check'] = answer['read_error'][0, 160] if answer['read_error']
185
+ rec['supersedes_block'] = answer['prior_ruling_block'] if answer['prior_ruling_block']
186
+ %w[plan_sha256 signals set_aside].each { |k| rec[k] = point[k] if point.key?(k) }
187
+ rec['feedback_sha256'] = sha256(feedback) if action == 'revise'
188
+ # An attested ruling carries the operator's reason, typed at the
189
+ # terminal; a caller's text is not attributed to the operator.
190
+ reason = att ? att['rationale_sha256'] : sha256(rationale)
191
+ rec['rationale_sha256'] = reason if reason
192
+ rec
193
+ end
194
+
195
+ # Appends a record through the tool's chain_record. Never raises: a
196
+ # human answer is never blocked by a recording failure (INV-D4).
197
+ def record(tool, rec)
198
+ out = tool.invoke_tool('chain_record', { 'logs' => [JSON.generate(rec)] })
199
+ text = Array(out).map { |b| b[:text] || b['text'] }.compact.join
200
+ m = text.match(/Block #(\d+) recorded successfully\./)
201
+ return { 'recorded' => true, 'block' => m[1].to_i, 'attestation' => rec['attestation'] } if m
202
+
203
+ { 'recorded' => false, 'error' => "chain_record replied: #{text[0, 160]}",
204
+ 'note' => 'the answer proceeded; this point does not count as evidence for delegation' }
205
+ rescue StandardError, ScriptError => e
206
+ { 'recorded' => false, 'error' => "#{e.class}: #{e.message[0, 160]}",
207
+ 'note' => 'the answer proceeded; this point does not count as evidence for delegation' }
208
+ end
209
+
210
+ def save_texts(session_dir, anchor:, source:, feedback: nil, rationale: nil)
211
+ return if feedback.to_s.empty? && rationale.to_s.empty?
212
+
213
+ entry = { 'anchor' => anchor, 'source' => source, 'at' => Time.now.utc.iso8601 }
214
+ entry['feedback'] = feedback unless feedback.to_s.empty?
215
+ entry['rationale'] = rationale unless rationale.to_s.empty?
216
+ File.open(File.join(session_dir, TEXTS_FILE), 'a') { |f| f.puts(JSON.generate(entry)) }
217
+ end
218
+
219
+ def texts_at(session_dir, anchor, source: 'terminal')
220
+ path = File.join(session_dir, TEXTS_FILE)
221
+ return [] unless File.exist?(path)
222
+
223
+ File.foreach(path).filter_map do |line|
224
+ e = JSON.parse(line) rescue nil
225
+ e if e.is_a?(Hash) && e['anchor'] == anchor && e['source'] == source
226
+ end
227
+ end
228
+
229
+ # Agent-authored text as it may reach the operator's terminal: control
230
+ # and format characters (escape sequences, carriage returns, newlines,
231
+ # bidirectional overrides) are shown as escapes, so the text cannot
232
+ # move the cursor, erase lines, or reorder what the operator reads.
233
+ #
234
+ # Invalid bytes are replaced, so no string the agent writes can make
235
+ # the display raise. max caps text the answer does not bind (goal,
236
+ # stop detail); the plan is shown whole.
237
+ def printable(text, max = nil)
238
+ s = text.to_s.dup.force_encoding(Encoding::UTF_8).scrub('?')
239
+ s = s.gsub(/[[:cntrl:]\p{Cf}\p{Zl}\p{Zp}]|[\p{Zs}&&[^ ]]/) { |c| format('\\u{%x}', c.ord) }
240
+ max && s.length > max ? "#{s[0, max]}…" : s
241
+ end
242
+
243
+ def json_text(value)
244
+ JSON.generate(value)
245
+ rescue StandardError
246
+ value.inspect
247
+ end
248
+
249
+ # Argument names that say where a step acts; shown first.
250
+ LOCATION_NAME = /path|file|dir|root|source|destination|target|url|name/i
251
+
252
+ # The plan exactly as the attested hash covers it: every step with
253
+ # every argument, location arguments first, nothing cut; any other
254
+ # field of the task; and the file that holds it. The agent wrote all
255
+ # of it, so all of it is escaped, and the summary is marked as the
256
+ # plan's own claim rather than something the driver observed.
257
+ def show_plan(tty_out, decision, state, plan_sha, decision_path)
258
+ lines = []
259
+ out = Object.new
260
+ out.define_singleton_method(:puts) { |line = ''| lines << line.to_s }
261
+ plan_lines(out, decision, state, plan_sha, decision_path)
262
+ lines.each { |l| tty_out.puts l }
263
+ show_locations(tty_out, decision['task_json'], lines, decision_path)
264
+ end
265
+
266
+ # Where the plan acts, repeated last, next to the prompt. The agent
267
+ # wrote the plan above and controls its length and order, so it can
268
+ # push any line of it out of sight; this block is short by
269
+ # construction, so what the answer most depends on stays in view.
270
+ def show_locations(tty_out, task, lines, decision_path)
271
+ steps = task['steps'].is_a?(Array) ? task['steps'] : []
272
+ tty_out.puts
273
+ tty_out.puts "Where this plan acts (#{steps.size} step(s); each step's tool and location arguments):"
274
+ steps.each_with_index do |st, i|
275
+ unless st.is_a?(Hash)
276
+ tty_out.puts " step #{i + 1}: (not a step; read the file)"
277
+ next
278
+ end
279
+ args = st['tool_arguments'].is_a?(Hash) ? st['tool_arguments'] : {}
280
+ locs = args.select { |k, _| k.to_s.match?(LOCATION_NAME) }
281
+ where = if locs.empty?
282
+ '(no location argument)'
283
+ else
284
+ locs.map { |k, v| "#{printable(k, 30)}=#{visible(json_text(v))}" }.join(' ')
285
+ end
286
+ tty_out.puts " step #{i + 1}: #{printable(st['tool_name'], 40)} #{where}"
287
+ end
288
+ tty_out.puts "The plan above took #{lines.size} lines (#{lines.sum(&:length)} characters). If it scrolled " \
289
+ "away, read it whole in #{File.expand_path(decision_path)}"
290
+ end
291
+
292
+ # A location value made legible: runs of spaces shown as a count, a
293
+ # long value cut with a marker that says so.
294
+ def visible(text, max = 120)
295
+ s = printable(text).gsub(/ {2,}/) { |m| "[#{m.size} spaces]" }
296
+ s.length > max ? "#{s[0, max]} (cut; read the file)" : s
297
+ end
298
+
299
+ def plan_lines(tty_out, decision, state, plan_sha, decision_path)
300
+ heading = { 'proposed' => 'Plan proposed (this answer decides whether it runs):',
301
+ 'paused_risk' => 'Plan stopped at the risk gate (this answer decides whether it runs):',
302
+ 'checkpoint' => 'Plan that has already run this cycle (approve starts the next cycle):' }
303
+ tty_out.puts heading[state] || 'Plan:'
304
+ tty_out.puts " summary, as the plan states it: #{printable(decision['summary'])}"
305
+ task = decision['task_json']
306
+ steps = task['steps']
307
+ if steps.is_a?(Array)
308
+ steps.each_with_index do |st, i|
309
+ unless st.is_a?(Hash)
310
+ tty_out.puts " step #{i + 1}: #{printable(json_text(st))}"
311
+ next
312
+ end
313
+ tty_out.puts " step #{i + 1}: id #{printable(st['step_id'])}, tool #{printable(st['tool_name'])}"
314
+ args = st['tool_arguments']
315
+ if args.is_a?(Hash)
316
+ args.keys.sort_by { |k| k.to_s.match?(LOCATION_NAME) ? 0 : 1 }.each do |k|
317
+ tty_out.puts " #{printable(k)}: #{printable(json_text(args[k]))}"
318
+ end
319
+ elsif !args.nil?
320
+ tty_out.puts " arguments: #{printable(json_text(args))}"
321
+ end
322
+ extra = st.reject { |k, _| %w[step_id tool_name tool_arguments].include?(k) }
323
+ tty_out.puts " other fields: #{printable(json_text(extra))}" unless extra.empty?
324
+ end
325
+ else
326
+ tty_out.puts " steps: #{printable(json_text(steps))}"
327
+ end
328
+ rest = task.reject { |k, _| k == 'steps' }
329
+ tty_out.puts " other plan fields: #{printable(json_text(rest))}" unless rest.empty?
330
+ tty_out.puts " The whole plan is in #{File.expand_path(decision_path)}"
331
+ tty_out.puts " sha256 #{plan_sha}"
332
+ end
333
+
334
+ # The answers the terminal offers at a state.
335
+ def answers_for(effective_state, intent)
336
+ return [] if effective_state == 'terminated'
337
+ return %w[adjudicate stop] if intent
338
+
339
+ case effective_state
340
+ when 'proposed' then %w[approve revise skip stop]
341
+ when 'paused_risk', 'paused_error' then %w[approve skip stop]
342
+ else %w[approve stop]
343
+ end
344
+ end
345
+
346
+ # ---- the terminal answer (INV-D8) ----
347
+
348
+ # Shows the stop on tty_out, reads the answer, an optional reason and
349
+ # the nonce on tty_in, and records the attestation only when the nonce
350
+ # matches and the session is still at the anchor shown. reload: a
351
+ # callable returning the session as persisted now. Returns
352
+ # { 'recorded' => bool, ... }.
353
+ def interactive_answer(session:, gate:, tty_in:, tty_out:, reload:, chain: nil,
354
+ nonce_value: ActClassification.nonce)
355
+ anchor = gate.current_anchor(session)
356
+ state = gate.effective_state(session)
357
+ intent = gate.unresolved_intent
358
+ offered = answers_for(state, intent)
359
+ if offered.empty?
360
+ tty_out.puts "Session #{session.session_id} is #{session.state}; there is nothing to answer."
361
+ return { 'recorded' => false, 'reason' => 'nothing_to_answer' }
362
+ end
363
+
364
+ decision = session.load_decision
365
+ plan_sha = PLAN_STATES.include?(state) ? plan_sha256(decision) : nil
366
+ stop = session.stop || {}
367
+ tty_out.puts "Session: #{printable(session.session_id)} (#{session.autonomous? ? 'autonomous' : 'manual'})"
368
+ tty_out.puts "Goal: #{printable(session.goal_name)}"
369
+ tty_out.puts "Stopped: #{printable(session.state)}, cycle #{session.cycle_number}, " \
370
+ "because #{printable(session.stop_kind)}"
371
+ tty_out.puts " #{printable(stop['detail'], 300)}" if stop['detail']
372
+ tty_out.puts "Anchor: #{anchor}"
373
+ if intent
374
+ tty_out.puts 'An act started and its outcome was never recorded. Answer adjudicate (reattempt or already_done) or stop.'
375
+ end
376
+ show_plan(tty_out, decision, state, plan_sha, session.send(:decision_path)) if plan_sha
377
+ tty_out.puts
378
+ tty_out.print "Your answer (#{offered.join(' / ')}): "
379
+ tty_out.flush
380
+ action = tty_in.gets.to_s.strip.downcase
381
+ unless offered.include?(action)
382
+ tty_out.puts 'Not one of the answers offered. Nothing was recorded.'
383
+ return { 'recorded' => false, 'reason' => 'not_offered' }
384
+ end
385
+
386
+ resolution = nil
387
+ feedback = nil
388
+ if action == 'adjudicate'
389
+ tty_out.print 'Resolution (reattempt / already_done): '
390
+ tty_out.flush
391
+ resolution = tty_in.gets.to_s.strip
392
+ unless %w[reattempt already_done].include?(resolution)
393
+ tty_out.puts 'Not a resolution. Nothing was recorded.'
394
+ return { 'recorded' => false, 'reason' => 'not_offered' }
395
+ end
396
+ elsif action == 'revise'
397
+ tty_out.print 'Feedback for the revision (one line): '
398
+ tty_out.flush
399
+ feedback = tty_in.gets.to_s.strip
400
+ if feedback.empty?
401
+ tty_out.puts 'A revision needs feedback. Nothing was recorded.'
402
+ return { 'recorded' => false, 'reason' => 'empty_feedback' }
403
+ end
404
+ end
405
+ tty_out.print 'Reason (optional, one line, kept off the chain): '
406
+ tty_out.flush
407
+ rationale = tty_in.gets.to_s.strip
408
+
409
+ tty_out.print "Type #{nonce_value} to record this answer (anything else cancels): "
410
+ tty_out.flush
411
+ unless tty_in.gets.to_s.strip == nonce_value
412
+ tty_out.puts 'Cancelled. Nothing was recorded.'
413
+ return { 'recorded' => false, 'reason' => 'nonce_mismatch' }
414
+ end
415
+
416
+ record = {
417
+ 'kind' => ATTESTATION_KIND, 'session_id' => session.session_id, 'anchor' => anchor,
418
+ 'state' => session.state, 'stop' => session.stop_kind, 'decision' => action,
419
+ 'attested' => true, 'attestation' => 'terminal_nonce', 'ruled_at' => Time.now.utc.iso8601
420
+ }
421
+ record['resolution'] = resolution if resolution
422
+ record['plan_sha256'] = plan_sha if plan_sha
423
+ record['feedback_sha256'] = sha256(feedback) if feedback
424
+ record['rationale_sha256'] = sha256(rationale) if sha256(rationale)
425
+
426
+ # Recorded under the advance lock, and only if no advance moved the
427
+ # session while the operator was typing.
428
+ result = gate.with_lock do
429
+ now = reload.call
430
+ if now.nil? || gate.current_anchor(now) != anchor
431
+ next { 'recorded' => false, 'reason' => 'moved_on',
432
+ 'detail' => "the session moved to #{now ? gate.current_anchor(now) : 'nowhere'}" }
433
+ end
434
+ # What was shown must still be what is answered: an act can open
435
+ # its intent, or a plan be rewritten, without the anchor moving.
436
+ now_state = gate.effective_state(now)
437
+ unless answers_for(now_state, gate.unresolved_intent).include?(action)
438
+ next { 'recorded' => false, 'reason' => 'moved_on',
439
+ 'detail' => "#{action} is no longer an answer this session can take" }
440
+ end
441
+ if (PLAN_STATES.include?(now_state) ? plan_sha256(now.load_decision) : nil) != plan_sha
442
+ next { 'recorded' => false, 'reason' => 'moved_on', 'detail' => 'the plan changed after it was shown' }
443
+ end
444
+
445
+ record['state'] = now.state
446
+ record['stop'] = now.stop_kind
447
+ save_texts(session.guard_dir, anchor: anchor, source: 'terminal', feedback: feedback, rationale: rationale)
448
+ chain ||= begin
449
+ require 'kairos_mcp/kairos_chain/chain'
450
+ ::KairosMcp::KairosChain::Chain.new
451
+ end
452
+ begin
453
+ block = chain.add_block([JSON.generate(record)])
454
+ rescue StandardError => e
455
+ next { 'recorded' => false, 'reason' => 'chain_refused', 'detail' => "#{e.class}: #{e.message[0, 160]}" }
456
+ end
457
+ { 'recorded' => true, 'block_index' => block.index, 'record' => record }
458
+ end
459
+
460
+ if result['status'] == 'busy'
461
+ tty_out.puts 'An advance is in flight on this session. Nothing was recorded; try again when it settles.'
462
+ return { 'recorded' => false, 'reason' => 'busy' }
463
+ end
464
+ unless result['recorded']
465
+ if result['reason'] == 'chain_refused'
466
+ tty_out.puts "The chain did not take the answer (#{result['detail']}). Nothing was recorded."
467
+ else
468
+ tty_out.puts "The session moved on while you were answering (#{result['detail']}). Nothing was recorded."
469
+ end
470
+ return result
471
+ end
472
+
473
+ tty_out.puts "Recorded as block ##{result['block_index']}."
474
+ tty_out.puts "Now send #{action}#{resolution ? " (#{resolution})" : ''} at this anchor (agent_step). " \
475
+ 'A different answer here is refused; a stop always goes through.'
476
+ result
477
+ end
478
+ end
479
+ end
480
+ end
481
+ end