kairos-chain 3.7.0 → 3.8.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: cef8ee3fd2052bb3d553e0c7ee0569d50f88fcfadea13dec6722122e2e71adb7
4
- data.tar.gz: de6425c1fa691e86ad4e3597f36733b9ca592cb017ad58b69d54aecb64b63dd6
3
+ metadata.gz: b7d045865d5f35b2ceba8abf858cc33219f88690feaf5591183b20154cda36e4
4
+ data.tar.gz: f333e43070e2268da45be99949d3c85217bfcf2a5d7d691e707fbe26f5b96796
5
5
  SHA512:
6
- metadata.gz: bc9dec7fd29edf94dce3de1a18fc90eb006695cb22475195aeb4422f6aa843a0efc1b2fa2e5796891768934c7307fc0687d0420f2883a099b700a80077ae4b7a
7
- data.tar.gz: db1e322c0b191ab12d949c403b6b04a5c6d99fccd9dd2e6528754a066a4bf3db26e4fb3e0cba4898826da13887d52f91c3018377a47aa5dd1ee25316f850f7df
6
+ metadata.gz: 4951cb6216a2f85212c12882c02337fc86e2a8abd906bcfd1322ca7d23760226692608a92626ea807c7a67b44c3f94b6eadd96ef88a6125a551dcca30a951954
7
+ data.tar.gz: ccf714bf15d9b54219373f9a83560943fcff476cb700a459cf5d62b5c02c02ce87c0986bf81d8b5b3d7c9708b1eb58c94c5e1ecb96c5c5909ec9bf6f562c1d78
data/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ 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.8.0] - 2026-03-30
8
+
9
+ ### Added
10
+
11
+ - **Agent Autonomous Mode** — Multi-cycle OODA loop execution
12
+ - `agent_start(autonomous: true)`: Enable autonomous mode. Session starts at
13
+ `[observed]` as before; autonomous loop begins on `agent_step(approve)`.
14
+ - 8 safety gates: mandate termination, goal drift detection, wall-clock timeout
15
+ (300s), aggregate LLM budget (60 calls), risk budget, post-ACT termination,
16
+ confidence-based early exit, checkpoint pause.
17
+ - New session states: `autonomous_cycling`, `paused_risk`, `paused_error`
18
+ - Resume handlers: `approve` at `paused_risk` re-checks risk and resumes ACT;
19
+ `approve`/`skip` at `paused_error` skips failed cycle and continues.
20
+ - `agent.yml` autonomous config: `max_total_llm_calls`, `max_duration_seconds`,
21
+ `min_cycles_before_exit`, `confidence_exit_threshold`.
22
+ - Design: 2 rounds x 3 LLMs. Implementation: 1 round x 3 LLMs. All HIGH fixed.
23
+
24
+ - **Mandate locking** — `Mandate.with_lock` for single-writer batch execution
25
+ - File-based exclusive lock (`flock`), non-blocking with `LockError`
26
+ - Atomic save via tmp+rename pattern
27
+ - `Mandate.reload` helper for in-lock refresh
28
+
29
+ - **CognitiveLoop call tracking** — `total_calls` attribute for aggregate
30
+ LLM budget enforcement across autonomous cycles
31
+
32
+ - **Goal drift detection** — Content-based hash (not name-only) at mandate
33
+ creation; per-cycle drift check with fail-open semantics
34
+
35
+ ### Changed
36
+
37
+ - `run_orient_decide` / `run_act_reflect` refactored into `_internal` (Hash return)
38
+ + wrapper (text_content) pattern. Manual mode behavior unchanged.
39
+ - Manual risk pause now sets session to `paused_risk` (was `terminated`),
40
+ enabling resume via `agent_step(approve)`.
41
+ - `MandateAdapter.to_mandate_proposal` uses `dig` for nil safety.
42
+
7
43
  ## [3.7.0] - 2026-03-29
8
44
 
9
45
  ### Added
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.7.0"
2
+ VERSION = "3.8.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -45,5 +45,12 @@ tool_blacklist:
45
45
  orient_tools_extra: []
46
46
  # - document_status # uncomment to enable draft checking during ORIENT
47
47
 
48
+ # Autonomous mode limits
49
+ autonomous:
50
+ max_total_llm_calls: 60 # across all cycles in one batch
51
+ max_duration_seconds: 300 # wall-clock timeout per batch (5 min)
52
+ min_cycles_before_exit: 2 # confidence exit disabled for first N cycles
53
+ confidence_exit_threshold: 0.9 # minimum confidence for early exit
54
+
48
55
  # Audit
49
56
  audit_level: summary
@@ -7,11 +7,17 @@ module KairosMcp
7
7
  module SkillSets
8
8
  module Agent
9
9
  class CognitiveLoop
10
+ FALLBACK_PROVIDERS = %w[claude_code].freeze
11
+
12
+ attr_reader :total_calls
13
+
10
14
  # @param caller_tool [BaseTool] the agent_step tool instance (has invoke_tool)
11
15
  # @param session [Session] current agent session
12
16
  def initialize(caller_tool, session)
13
17
  @caller = caller_tool
14
18
  @session = session
19
+ @fallback_attempted = false
20
+ @total_calls = 0
15
21
  end
16
22
 
17
23
  # Generic phase runner for ORIENT, REFLECT, and DECIDE_PREP.
@@ -29,14 +35,13 @@ module KairosMcp
29
35
  'stop_reason' => 'budget' }
30
36
  end
31
37
 
32
- llm_result = @caller.invoke_tool('llm_call', {
38
+ @total_calls += 1
39
+ parsed = call_llm_with_fallback(
33
40
  'messages' => messages,
34
41
  'system' => system_prompt,
35
42
  'tools' => available_tools,
36
43
  'invocation_context_json' => @session.invocation_context.to_json
37
- }, context: @session.invocation_context)
38
-
39
- parsed = JSON.parse(llm_result.map { |b| b[:text] || b['text'] }.compact.join)
44
+ )
40
45
  return { 'error' => parsed['error'] } if parsed['status'] == 'error'
41
46
 
42
47
  response = parsed['response']
@@ -79,14 +84,14 @@ module KairosMcp
79
84
  return { 'error' => 'Budget exceeded for DECIDE phase' }
80
85
  end
81
86
 
82
- llm_result = @caller.invoke_tool('llm_call', {
87
+ @total_calls += 1
88
+
89
+ parsed = call_llm_with_fallback(
83
90
  'messages' => messages,
84
91
  'system' => system_prompt,
85
92
  'tools' => [],
86
93
  'invocation_context_json' => @session.invocation_context.to_json
87
- }, context: @session.invocation_context)
88
-
89
- parsed = JSON.parse(llm_result.map { |b| b[:text] || b['text'] }.compact.join)
94
+ )
90
95
  return { 'error' => parsed['error'] } if parsed['status'] == 'error'
91
96
 
92
97
  response = parsed['response']
@@ -110,7 +115,7 @@ module KairosMcp
110
115
  begin
111
116
  decision = JSON.parse(json_str)
112
117
  task_json_str = JSON.generate(decision['task_json'])
113
- Autoexec::TaskDsl.from_json(task_json_str)
118
+ ::Autoexec::TaskDsl.from_json(task_json_str)
114
119
  return { 'decision_payload' => decision }
115
120
  rescue => e
116
121
  if attempts >= max_repair
@@ -127,6 +132,61 @@ module KairosMcp
127
132
 
128
133
  private
129
134
 
135
+ # Call llm_call with automatic provider fallback on auth errors.
136
+ # Tries the configured provider first. On auth_error, switches to
137
+ # fallback providers (claude_code) via llm_configure, then retries once.
138
+ def call_llm_with_fallback(arguments)
139
+ llm_result = @caller.invoke_tool('llm_call', arguments,
140
+ context: @session.invocation_context)
141
+ parsed = JSON.parse(llm_result.map { |b| b[:text] || b['text'] }.compact.join)
142
+
143
+ # If not an auth error, or already tried fallback, return as-is
144
+ error_info = parsed['error']
145
+ if !error_info || !error_info.is_a?(Hash) || error_info['type'] != 'auth_error' || @fallback_attempted
146
+ return parsed
147
+ end
148
+
149
+ # Attempt provider fallback
150
+ original_provider = error_info['provider'] || 'configured'
151
+ warn "[agent] Auth error from #{original_provider}, attempting provider fallback"
152
+
153
+ FALLBACK_PROVIDERS.each do |fallback|
154
+ @fallback_attempted = true
155
+ configure_result = try_configure_provider(fallback)
156
+ next unless configure_result
157
+
158
+ warn "[agent] Switched to provider: #{fallback}"
159
+ retry_result = @caller.invoke_tool('llm_call', arguments,
160
+ context: @session.invocation_context)
161
+ retry_parsed = JSON.parse(retry_result.map { |b| b[:text] || b['text'] }.compact.join)
162
+
163
+ # If this provider also fails with auth_error, try next
164
+ retry_error = retry_parsed['error']
165
+ if retry_error.is_a?(Hash) && retry_error['type'] == 'auth_error'
166
+ warn "[agent] Fallback provider #{fallback} also failed: #{retry_error['message']}"
167
+ next
168
+ end
169
+
170
+ return retry_parsed
171
+ end
172
+
173
+ # All fallbacks exhausted — return original error with fallback info
174
+ parsed['error']['fallback_attempted'] = true
175
+ parsed['error']['fallback_exhausted'] = true
176
+ parsed
177
+ end
178
+
179
+ def try_configure_provider(provider)
180
+ args = { 'provider' => provider }
181
+ result = @caller.invoke_tool('llm_configure', args,
182
+ context: @session.invocation_context)
183
+ parsed = JSON.parse(result.map { |b| b[:text] || b['text'] }.compact.join)
184
+ parsed['status'] == 'ok'
185
+ rescue StandardError => e
186
+ warn "[agent] Failed to configure provider #{provider}: #{e.message}"
187
+ false
188
+ end
189
+
130
190
  def extract_json(content)
131
191
  JSON.parse(content)
132
192
  content
@@ -11,7 +11,7 @@ module KairosMcp
11
11
  def self.to_mandate_proposal(decision_payload)
12
12
  {
13
13
  autoexec_task: {
14
- steps: (decision_payload['task_json']['steps'] || []).map { |s|
14
+ steps: (decision_payload.dig('task_json', 'steps') || []).map { |s|
15
15
  { risk: s['risk'] || 'low', tool_name: s['tool_name'] }
16
16
  }
17
17
  },
@@ -8,19 +8,25 @@ module KairosMcp
8
8
  module Agent
9
9
  class Session
10
10
  attr_reader :session_id, :mandate_id, :goal_name, :invocation_context,
11
- :state, :cycle_number, :config
11
+ :state, :cycle_number, :config, :autonomous
12
12
 
13
- def initialize(session_id:, mandate_id:, goal_name:, invocation_context:, config:)
13
+ def initialize(session_id:, mandate_id:, goal_name:, invocation_context:, config:,
14
+ autonomous: false)
14
15
  @session_id = session_id
15
16
  @mandate_id = mandate_id
16
17
  @goal_name = goal_name
17
18
  @invocation_context = invocation_context
18
19
  @config = config
20
+ @autonomous = autonomous
19
21
  @state = 'created'
20
22
  @cycle_number = 0
21
23
  @snapshots = []
22
24
  end
23
25
 
26
+ def autonomous?
27
+ @autonomous == true
28
+ end
29
+
24
30
  # Per-phase budget configuration.
25
31
  # Returns defaults if the phase is not configured.
26
32
  def phase_config(phase_name)
@@ -109,7 +115,7 @@ module KairosMcp
109
115
  data = {
110
116
  session_id: @session_id, mandate_id: @mandate_id,
111
117
  goal_name: @goal_name, state: @state, cycle_number: @cycle_number,
112
- config: @config,
118
+ config: @config, autonomous: @autonomous,
113
119
  invocation_context: @invocation_context.to_h
114
120
  }
115
121
  File.write(state_path, JSON.pretty_generate(data))
@@ -133,7 +139,8 @@ module KairosMcp
133
139
  mandate_id: data['mandate_id'],
134
140
  goal_name: data['goal_name'],
135
141
  invocation_context: ctx,
136
- config: data['config']
142
+ config: data['config'],
143
+ autonomous: data['autonomous'] || false
137
144
  )
138
145
  session.instance_variable_set(:@state, data['state'])
139
146
  session.instance_variable_set(:@cycle_number, data['cycle_number'] || 0)
@@ -52,6 +52,12 @@ module KairosMcp
52
52
  risk_budget: {
53
53
  type: 'string',
54
54
  description: 'Maximum risk level: "low" or "medium" (default: "low")'
55
+ },
56
+ autonomous: {
57
+ type: 'boolean',
58
+ description: 'Enable autonomous mode (default: false). ' \
59
+ 'Session starts at [observed] regardless. ' \
60
+ 'Autonomous loop begins on first agent_step(approve).'
55
61
  }
56
62
  },
57
63
  required: ['goal_name']
@@ -63,10 +69,14 @@ module KairosMcp
63
69
  max_cycles = arguments['max_cycles'] || 3
64
70
  checkpoint_every = arguments['checkpoint_every'] || 1
65
71
  risk_budget = arguments['risk_budget'] || 'low'
72
+ autonomous = arguments['autonomous'] == true
73
+
74
+ # Pre-resolve goal content for content-based drift detection hash
75
+ pre_obs = run_observe(goal_name)
76
+ goal_content_for_hash = pre_obs['goal_content'] || goal_name
77
+ goal_hash = Digest::SHA256.hexdigest(goal_content_for_hash)[0..15]
66
78
 
67
- # Create mandate via Autonomos
68
- goal_hash = Digest::SHA256.hexdigest(goal_name)[0..15]
69
- mandate = Autonomos::Mandate.create(
79
+ mandate = ::Autonomos::Mandate.create(
70
80
  goal_name: goal_name,
71
81
  goal_hash: goal_hash,
72
82
  max_cycles: max_cycles,
@@ -85,11 +95,12 @@ module KairosMcp
85
95
  mandate_id: mandate[:mandate_id],
86
96
  goal_name: goal_name,
87
97
  invocation_context: ctx,
88
- config: config
98
+ config: config,
99
+ autonomous: autonomous
89
100
  )
90
101
 
91
- # OBSERVE (no LLM direct Ruby)
92
- observation = run_observe(goal_name)
102
+ # OBSERVE: reuse pre-resolved observation (avoids duplicate L2/L1 lookups)
103
+ observation = pre_obs
93
104
 
94
105
  session.save_observation(observation)
95
106
  session.update_state('observed')
@@ -100,6 +111,7 @@ module KairosMcp
100
111
  'session_id' => session_id,
101
112
  'mandate_id' => mandate[:mandate_id],
102
113
  'state' => 'observed',
114
+ 'autonomous' => autonomous,
103
115
  'observation' => observation
104
116
  }
105
117
 
@@ -151,10 +163,10 @@ module KairosMcp
151
163
  # Gather observation data without LLM
152
164
  observation = { 'goal_name' => goal_name, 'timestamp' => Time.now.iso8601 }
153
165
 
154
- # Try to load goal from L2/L1
155
- if defined?(Autonomos::Ooda)
166
+ # Load environment data via Autonomos::Ooda
167
+ if defined?(::Autonomos::Ooda)
156
168
  begin
157
- helper = Class.new { include Autonomos::Ooda }.new
169
+ helper = Class.new { include ::Autonomos::Ooda }.new
158
170
  ooda_obs = helper.observe(goal_name)
159
171
  observation.merge!(ooda_obs.transform_keys(&:to_s)) if ooda_obs.is_a?(Hash)
160
172
  rescue StandardError => e
@@ -162,8 +174,47 @@ module KairosMcp
162
174
  end
163
175
  end
164
176
 
177
+ # Load goal content from L2/L1 so Orient has context to analyze
178
+ begin
179
+ if defined?(::Autonomos::Ooda)
180
+ helper = Class.new { include ::Autonomos::Ooda }.new
181
+ goal = helper.load_goal(goal_name)
182
+ else
183
+ goal = load_goal_fallback(goal_name)
184
+ end
185
+ if goal && goal[:found]
186
+ observation['goal_content'] = goal[:content]
187
+ observation['goal_source'] = goal[:source].to_s
188
+ end
189
+ rescue StandardError => e
190
+ observation['goal_load_error'] = e.message
191
+ end
192
+
165
193
  observation
166
194
  end
195
+
196
+ def load_goal_fallback(goal_name)
197
+ # Direct L2/L1 lookup when Autonomos::Ooda is unavailable
198
+ if defined?(KairosMcp::ContextManager)
199
+ ctx_mgr = KairosMcp::ContextManager.new
200
+ ctx_mgr.list_sessions.each do |session|
201
+ entry = ctx_mgr.get_context(session[:session_id], goal_name)
202
+ if entry && entry.respond_to?(:content) && entry.content && !entry.content.strip.empty?
203
+ return { content: entry.content, found: true, source: :l2 }
204
+ end
205
+ end
206
+ end
207
+ if defined?(KairosMcp::KnowledgeProvider)
208
+ provider = KairosMcp::KnowledgeProvider.new(nil)
209
+ result = provider.get(goal_name)
210
+ if result && result[:content] && !result[:content].strip.empty?
211
+ return { content: result[:content], found: true, source: :l1 }
212
+ end
213
+ end
214
+ { content: nil, found: false }
215
+ rescue StandardError
216
+ { content: nil, found: false }
217
+ end
167
218
  end
168
219
  end
169
220
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'digest'
4
5
  require_relative '../lib/agent'
5
6
 
6
7
  module KairosMcp
@@ -96,12 +97,16 @@ module KairosMcp
96
97
 
97
98
  def handle_approve(session)
98
99
  case session.state
99
- when 'observed'
100
- run_orient_decide(session)
100
+ when 'observed', 'autonomous_cycling'
101
+ session.autonomous? ? run_autonomous_loop(session) : run_orient_decide(session)
101
102
  when 'proposed'
102
103
  run_act_reflect(session)
103
104
  when 'checkpoint'
104
- run_next_cycle(session)
105
+ session.autonomous? ? run_autonomous_loop(session) : run_next_cycle(session)
106
+ when 'paused_risk'
107
+ handle_resume_from_risk(session)
108
+ when 'paused_error'
109
+ handle_resume_from_error(session)
105
110
  else
106
111
  error_result("Cannot approve in state: #{session.state}")
107
112
  end
@@ -113,11 +118,15 @@ module KairosMcp
113
118
  end
114
119
 
115
120
  def handle_skip(session)
116
- return error_result("skip only valid at [proposed]") unless session.state == 'proposed'
121
+ return error_result("skip not valid in state: #{session.state}") unless %w[proposed paused_error].include?(session.state)
122
+ return handle_resume_from_error(session) if session.state == 'paused_error'
117
123
  # Skip ACT, go directly to REFLECT with "skipped"
118
124
  session.update_state('reflecting')
119
125
  act_result = { 'skipped' => true, 'summary' => 'skipped' }
120
- reflect_result = run_reflect(session, act_result)
126
+ reflect_loop = CognitiveLoop.new(self, session)
127
+ messages = [{ 'role' => 'user', 'content' => build_reflect_prompt(session, act_result) }]
128
+ reflect_raw = reflect_loop.run_phase('reflect', reflect_system_prompt, messages, [])
129
+ reflect_result = reflect_raw['content'] ? parse_reflect_json(reflect_raw['content']) : { 'confidence' => 0.0 }
121
130
 
122
131
  # Chain recording + progress (same as run_act_reflect)
123
132
  decision_payload = session.load_decision || {}
@@ -138,98 +147,411 @@ module KairosMcp
138
147
 
139
148
  # ---- ORIENT + DECIDE ----
140
149
 
141
- def run_orient_decide(session)
150
+ # Internal version: returns Hash, never calls text_content.
151
+ # Used by both manual wrapper and autonomous loop.
152
+ # mandate_override: pass in-memory mandate from autonomous loop to avoid stale reads.
153
+ def run_orient_decide_internal(session, mandate_override: nil)
142
154
  loop_inst = CognitiveLoop.new(self, session)
143
155
 
144
- # Load observation (Fix #6: pass to ORIENT)
145
156
  observation = session.load_observation
146
157
  observation_text = observation ? JSON.generate(observation) : '(no observation data)'
147
158
 
148
- # ORIENT
149
159
  session.update_state('orienting')
150
160
  orient_prompt = build_orient_prompt(session, observation_text)
151
161
  messages = [{ 'role' => 'user', 'content' => orient_prompt }]
152
162
 
153
163
  orient_result = loop_inst.run_phase('orient', orient_system_prompt, messages, orient_tools(session))
154
- return error_with_state(session, 'observed', orient_result) if orient_result['error']
164
+ if orient_result['error']
165
+ session.update_state('observed')
166
+ session.save
167
+ return { error: orient_result['error'], llm_calls: loop_inst.total_calls }
168
+ end
155
169
 
156
- # DECIDE (single-stage; see design v0.4 sec 3.3 for future extension)
157
170
  session.update_state('deciding')
158
171
  decide_messages = [{ 'role' => 'user', 'content' => build_decide_prompt(session, orient_result) }]
159
172
 
160
173
  decide_result = loop_inst.run_decide(decide_system_prompt, decide_messages)
161
- return error_with_state(session, 'observed', decide_result) if decide_result['error']
174
+ if decide_result['error']
175
+ session.update_state('observed')
176
+ session.save
177
+ return { error: decide_result['error'], llm_calls: loop_inst.total_calls }
178
+ end
162
179
 
163
- # M4: Loop detection (after DECIDE, before presenting to user)
164
180
  decision_payload = decide_result['decision_payload']
165
- loop_term = check_loop_detection(session, orient_result, decision_payload)
181
+ loop_term = check_loop_detection(session, orient_result, decision_payload,
182
+ mandate_override: mandate_override)
166
183
  if loop_term
184
+ return { loop_detected: true, llm_calls: loop_inst.total_calls }
185
+ end
186
+
187
+ session.save_decision(decision_payload)
188
+ { orient: orient_result, decision_payload: decision_payload,
189
+ loop_detected: false, error: nil, llm_calls: loop_inst.total_calls }
190
+ end
191
+
192
+ # Manual mode wrapper: pure format converter
193
+ def run_orient_decide(session)
194
+ result = run_orient_decide_internal(session)
195
+ if result[:error]
196
+ return error_with_state(session, 'observed', { 'error' => result[:error] })
197
+ end
198
+ if result[:loop_detected]
167
199
  return text_content(JSON.generate({
168
200
  'status' => 'terminated', 'reason' => 'loop_detected',
169
201
  'session_id' => session.session_id
170
202
  }))
171
203
  end
172
204
 
173
- # Fix #1: persist decision for proposed→ACT transition
174
- session.save_decision(decision_payload)
175
205
  session.update_state('proposed')
176
206
  session.save
177
-
178
207
  text_content(JSON.generate({
179
208
  'status' => 'ok', 'session_id' => session.session_id,
180
209
  'state' => 'proposed',
181
- 'orient' => summarize_orient(orient_result),
182
- 'decision_payload' => decide_result['decision_payload']
210
+ 'orient' => summarize_orient(result[:orient]),
211
+ 'decision_payload' => result[:decision_payload]
183
212
  }))
184
213
  end
185
214
 
186
215
  # ---- ACT + REFLECT ----
187
216
 
188
- def run_act_reflect(session)
189
- decision_payload = load_last_decision(session)
190
- return error_result("No decision payload found") unless decision_payload
191
-
192
- # Check risk before ACT
193
- proposal = MandateAdapter.to_mandate_proposal(decision_payload)
194
- mandate = Autonomos::Mandate.load(session.mandate_id)
195
- if Autonomos::Mandate.risk_exceeds_budget?(proposal, mandate[:risk_budget])
196
- Autonomos::Mandate.update_status(session.mandate_id, 'paused_risk_exceeded')
197
- session.update_state('terminated')
198
- session.save
199
- return text_content(JSON.generate({
200
- 'status' => 'paused', 'reason' => 'risk_exceeded',
201
- 'session_id' => session.session_id, 'state' => 'terminated'
202
- }))
203
- end
217
+ # Internal version: returns Hash, never calls text_content.
218
+ # Increments cycle and saves session. Does NOT set final state.
219
+ def run_act_reflect_internal(session)
220
+ decision_payload = session.load_decision
221
+ return { act_error: 'No decision payload found', llm_calls: 0 } unless decision_payload
204
222
 
205
- # ACT via autoexec with derived context
206
223
  session.update_state('acting')
207
224
  act_result = run_act(session, decision_payload)
208
225
 
209
- # REFLECT
210
226
  session.update_state('reflecting')
211
- reflect_result = run_reflect(session, act_result)
227
+ reflect_loop = CognitiveLoop.new(self, session)
228
+ messages = [{ 'role' => 'user', 'content' => build_reflect_prompt(session, act_result) }]
229
+ reflect_raw = reflect_loop.run_phase('reflect', reflect_system_prompt, messages, [])
230
+ reflect_result = if reflect_raw['content']
231
+ parse_reflect_json(reflect_raw['content'])
232
+ else
233
+ { 'confidence' => 0.0, 'error' => reflect_raw['error'] || 'no content' }
234
+ end
212
235
 
213
- # Record cycle
214
236
  record_agent_cycle(session, decision_payload, act_result, reflect_result)
215
237
 
216
- # M5: Save cumulative progress after REFLECT (1-based cycle numbering)
217
238
  act_summary = act_result['summary'] || act_result['error'] || 'completed'
218
239
  decision_summary = decision_payload['summary'] || ''
219
240
  session.save_progress(reflect_result, session.cycle_number + 1, act_summary, decision_summary)
220
241
 
221
242
  session.increment_cycle
222
- session.update_state('checkpoint')
223
243
  session.save
224
244
 
245
+ act_succeeded = !act_result['error'] && act_result['summary'] != 'failed'
246
+
247
+ { act: act_result, reflect: reflect_result, cycle: session.cycle_number,
248
+ act_error: act_result['error'], act_succeeded: act_succeeded,
249
+ llm_calls: reflect_loop.total_calls }
250
+ end
251
+
252
+ # Manual mode wrapper: pure format converter
253
+ def run_act_reflect(session)
254
+ decision_payload = load_last_decision(session)
255
+ return error_result("No decision payload found") unless decision_payload
256
+
257
+ proposal = MandateAdapter.to_mandate_proposal(decision_payload)
258
+ mandate = ::Autonomos::Mandate.load(session.mandate_id)
259
+ if ::Autonomos::Mandate.risk_exceeds_budget?(proposal, mandate[:risk_budget])
260
+ ::Autonomos::Mandate.update_status(session.mandate_id, 'paused_risk_exceeded')
261
+ session.update_state('paused_risk')
262
+ session.save
263
+ return text_content(JSON.generate({
264
+ 'status' => 'paused', 'reason' => 'risk_exceeded',
265
+ 'session_id' => session.session_id, 'state' => 'paused_risk'
266
+ }))
267
+ end
268
+
269
+ result = run_act_reflect_internal(session)
270
+ session.update_state('checkpoint')
271
+ session.save
225
272
  text_content(JSON.generate({
226
273
  'status' => 'ok', 'session_id' => session.session_id,
227
274
  'state' => 'checkpoint',
228
- 'act_summary' => act_result['summary'] || 'completed',
229
- 'reflect' => reflect_result
275
+ 'act_summary' => result.dig(:act, 'summary') || 'completed',
276
+ 'reflect' => result[:reflect]
230
277
  }))
231
278
  end
232
279
 
280
+ # ---- AUTONOMOUS LOOP ----
281
+
282
+ def run_autonomous_loop(session)
283
+ auto_cfg = session.config['autonomous'] || {}
284
+ max_total_llm = auto_cfg['max_total_llm_calls'] || 60
285
+ max_duration = auto_cfg['max_duration_seconds'] || 300
286
+ min_exit_cycles = auto_cfg['min_cycles_before_exit'] || 2
287
+ confidence_threshold = auto_cfg['confidence_exit_threshold'] || 0.9
288
+
289
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
290
+ total_llm_calls = 0
291
+ results = []
292
+
293
+ ::Autonomos::Mandate.with_lock(session.mandate_id) do |mandate|
294
+ while session.cycle_number < (mandate[:max_cycles] || 3)
295
+ session.update_state('autonomous_cycling')
296
+ session.save
297
+
298
+ # Gate 1: Mandate termination
299
+ term_reason = ::Autonomos::Mandate.check_termination(mandate)
300
+ if term_reason
301
+ mandate[:status] = 'terminated'
302
+ ::Autonomos::Mandate.save(session.mandate_id, mandate)
303
+ session.update_state('terminated')
304
+ return finalize_autonomous(session, results, terminated: term_reason)
305
+ end
306
+
307
+ # Gate 2: Goal drift
308
+ if goal_drifted?(session, mandate)
309
+ mandate[:status] = 'paused_goal_drift'
310
+ ::Autonomos::Mandate.save(session.mandate_id, mandate)
311
+ session.update_state('checkpoint')
312
+ session.save
313
+ return finalize_autonomous(session, results, checkpoint: true,
314
+ warning: 'goal_content_changed')
315
+ end
316
+
317
+ # Gate 3: Wall-clock timeout
318
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
319
+ if elapsed > max_duration
320
+ session.update_state('checkpoint')
321
+ session.save
322
+ return finalize_autonomous(session, results, checkpoint: true,
323
+ paused: 'timeout')
324
+ end
325
+
326
+ # Gate 4: Aggregate LLM budget
327
+ if total_llm_calls >= max_total_llm
328
+ session.update_state('checkpoint')
329
+ session.save
330
+ return finalize_autonomous(session, results, checkpoint: true,
331
+ paused: 'llm_budget_exceeded')
332
+ end
333
+
334
+ # OBSERVE (cycle 2+; cycle 1 already observed by agent_start)
335
+ if session.cycle_number > 0
336
+ observation = run_observe_for_next_cycle(session)
337
+ session.save_observation(observation)
338
+ end
339
+
340
+ # ORIENT + DECIDE (pass in-memory mandate to avoid stale reads)
341
+ od_result = run_orient_decide_internal(session, mandate_override: mandate)
342
+ total_llm_calls += od_result[:llm_calls] || 0
343
+ if od_result[:error]
344
+ session.update_state('paused_error')
345
+ session.save
346
+ return finalize_autonomous(session, results, error: od_result[:error])
347
+ end
348
+ if od_result[:loop_detected]
349
+ session.update_state('terminated')
350
+ return finalize_autonomous(session, results, terminated: 'loop_detected')
351
+ end
352
+
353
+ # Gate 5: Risk budget (after loop detection, existing order)
354
+ decision_payload = session.load_decision
355
+ proposal = MandateAdapter.to_mandate_proposal(decision_payload)
356
+ if ::Autonomos::Mandate.risk_exceeds_budget?(proposal, mandate[:risk_budget])
357
+ mandate[:status] = 'paused_risk_exceeded'
358
+ ::Autonomos::Mandate.save(session.mandate_id, mandate)
359
+ session.update_state('paused_risk')
360
+ session.save
361
+ return finalize_autonomous(session, results, paused: 'risk_exceeded')
362
+ end
363
+
364
+ # ACT + REFLECT
365
+ ar_result = run_act_reflect_internal(session)
366
+ total_llm_calls += ar_result[:llm_calls] || 0
367
+ results << ar_result
368
+ if ar_result[:act_error]
369
+ session.update_state('paused_error')
370
+ session.save
371
+ return finalize_autonomous(session, results, paused: 'act_failed',
372
+ error: ar_result[:act_error])
373
+ end
374
+
375
+ # Gate 6: Post-ACT termination (record_cycle may have incremented errors)
376
+ mandate = ::Autonomos::Mandate.reload(session.mandate_id)
377
+ term_reason = ::Autonomos::Mandate.check_termination(mandate)
378
+ if term_reason
379
+ session.update_state('terminated')
380
+ return finalize_autonomous(session, results, terminated: term_reason)
381
+ end
382
+
383
+ # Gate 7: Confidence-based early exit
384
+ if session.cycle_number >= min_exit_cycles
385
+ confidence = clamp_confidence(ar_result.dig(:reflect, 'confidence'))
386
+ remaining = ar_result.dig(:reflect, 'remaining')
387
+ if confidence >= confidence_threshold &&
388
+ remaining.is_a?(Array) && remaining.empty? &&
389
+ ar_result[:act_succeeded]
390
+ session.update_state('terminated')
391
+ return finalize_autonomous(session, results, terminated: 'goal_achieved')
392
+ end
393
+ end
394
+
395
+ # Gate 8: Checkpoint pause
396
+ checkpoint_every = mandate[:checkpoint_every] || 1
397
+ if session.cycle_number > 0 &&
398
+ (session.cycle_number % checkpoint_every).zero?
399
+ mandate[:status] = 'paused_at_checkpoint'
400
+ ::Autonomos::Mandate.save(session.mandate_id, mandate)
401
+ session.update_state('checkpoint')
402
+ session.save
403
+ return finalize_autonomous(session, results, checkpoint: true)
404
+ end
405
+ end
406
+
407
+ # All cycles exhausted (inside lock)
408
+ session.update_state('terminated')
409
+ session.save
410
+ finalize_autonomous(session, results, terminated: 'max_cycles_reached')
411
+ end
412
+ rescue ::Autonomos::Mandate::LockError => e
413
+ error_result("Session locked: #{e.message}")
414
+ end
415
+
416
+ def finalize_autonomous(session, cycle_results, terminated: nil, paused: nil,
417
+ checkpoint: nil, error: nil, warning: nil)
418
+ session.save
419
+
420
+ status = if checkpoint then 'checkpoint'
421
+ elsif paused then 'paused'
422
+ elsif error then 'error'
423
+ else 'completed'
424
+ end
425
+
426
+ text_content(JSON.generate({
427
+ 'status' => status,
428
+ 'session_id' => session.session_id,
429
+ 'state' => session.state,
430
+ 'cycles_completed' => session.cycle_number,
431
+ 'terminated_reason' => terminated,
432
+ 'paused_reason' => paused,
433
+ 'error' => error,
434
+ 'warning' => warning,
435
+ 'cycle_results' => cycle_results.map { |r|
436
+ { 'cycle' => r[:cycle],
437
+ 'act_summary' => r.dig(:act, 'summary') || 'completed',
438
+ 'confidence' => clamp_confidence(r.dig(:reflect, 'confidence')),
439
+ 'remaining_count' => Array(r.dig(:reflect, 'remaining')).size }
440
+ }
441
+ }))
442
+ end
443
+
444
+ def clamp_confidence(raw)
445
+ val = raw.to_f
446
+ [[val, 0.0].max, 1.0].min
447
+ end
448
+
449
+ def goal_drifted?(session, mandate)
450
+ current_goal = load_goal_content(session.goal_name)
451
+ current_hash = Digest::SHA256.hexdigest(current_goal || session.goal_name)[0..15]
452
+ current_hash != mandate[:goal_hash].to_s
453
+ rescue StandardError
454
+ false
455
+ end
456
+
457
+ def load_goal_content(goal_name)
458
+ # Use Ooda (same path as agent_start's run_observe)
459
+ if defined?(::Autonomos::Ooda)
460
+ helper = Class.new { include ::Autonomos::Ooda }.new
461
+ goal = helper.load_goal(goal_name)
462
+ return goal[:content] if goal && goal[:found]
463
+ end
464
+ # Fallback: direct L1 lookup (matches agent_start's load_goal_fallback)
465
+ if defined?(KairosMcp::KnowledgeProvider)
466
+ provider = KairosMcp::KnowledgeProvider.new(nil)
467
+ result = provider.get(goal_name)
468
+ return result[:content] if result && result[:content] && !result[:content].strip.empty?
469
+ end
470
+ nil
471
+ rescue StandardError
472
+ nil
473
+ end
474
+
475
+ # ---- RESUME HANDLERS ----
476
+
477
+ def handle_resume_from_risk(session)
478
+ mandate = ::Autonomos::Mandate.load(session.mandate_id)
479
+ decision_payload = session.load_decision
480
+ return error_result("No decision to re-check") unless decision_payload
481
+
482
+ proposal = MandateAdapter.to_mandate_proposal(decision_payload)
483
+ if ::Autonomos::Mandate.risk_exceeds_budget?(proposal, mandate[:risk_budget])
484
+ return text_content(JSON.generate({
485
+ 'status' => 'still_paused', 'reason' => 'risk_still_exceeded',
486
+ 'session_id' => session.session_id,
487
+ 'hint' => 'Update mandate risk_budget or call stop'
488
+ }))
489
+ end
490
+
491
+ # Resume: risk now within budget. Execute the paused proposal (ACT+REFLECT),
492
+ # not a new ORIENT+DECIDE cycle. decision_payload is already saved.
493
+ mandate[:status] = 'active'
494
+ ::Autonomos::Mandate.save(session.mandate_id, mandate)
495
+
496
+ if session.autonomous?
497
+ # In autonomous mode, run ACT+REFLECT for the paused proposal,
498
+ # then continue the autonomous loop from next cycle.
499
+ ar_result = run_act_reflect_internal(session)
500
+ if ar_result[:act_error]
501
+ session.update_state('paused_error')
502
+ session.save
503
+ return finalize_autonomous(session, [ar_result], paused: 'act_failed',
504
+ error: ar_result[:act_error])
505
+ end
506
+ # Continue to next cycle in autonomous loop
507
+ session.update_state('observed')
508
+ session.save
509
+ run_autonomous_loop(session)
510
+ else
511
+ # Manual mode: resume at ACT+REFLECT for the existing proposal
512
+ result = run_act_reflect_internal(session)
513
+ session.update_state('checkpoint')
514
+ session.save
515
+ text_content(JSON.generate({
516
+ 'status' => 'ok', 'session_id' => session.session_id,
517
+ 'state' => 'checkpoint',
518
+ 'act_summary' => result.dig(:act, 'summary') || 'completed',
519
+ 'reflect' => result[:reflect]
520
+ }))
521
+ end
522
+ end
523
+
524
+ def handle_resume_from_error(session)
525
+ # Record skipped cycle in mandate before advancing
526
+ begin
527
+ ::Autonomos::Mandate.record_cycle(
528
+ session.mandate_id,
529
+ cycle_id: "#{session.session_id}_cycle#{session.cycle_number}_skipped",
530
+ evaluation: 'failed'
531
+ )
532
+ rescue StandardError => e
533
+ warn "[agent] Failed to record skipped cycle: #{e.message}"
534
+ end
535
+
536
+ # Do NOT increment session.cycle_number here — the next
537
+ # run_act_reflect_internal will do it after a successful cycle.
538
+ # We only record the skip in mandate.
539
+ observation = run_observe_for_next_cycle(session)
540
+ session.save_observation(observation)
541
+ session.update_state('observed')
542
+ session.save
543
+
544
+ if session.autonomous?
545
+ run_autonomous_loop(session)
546
+ else
547
+ text_content(JSON.generate({
548
+ 'status' => 'ok', 'session_id' => session.session_id,
549
+ 'state' => 'observed', 'cycle' => session.cycle_number + 1,
550
+ 'observation' => observation
551
+ }))
552
+ end
553
+ end
554
+
233
555
  def run_act(session, decision_payload)
234
556
  act_ctx = session.invocation_context.derive(
235
557
  blacklist_remove: %w[autoexec_plan autoexec_run]
@@ -265,20 +587,21 @@ module KairosMcp
265
587
  { 'error' => "ACT failed: #{e.message}" }
266
588
  end
267
589
 
268
- def run_reflect(session, act_result)
269
- loop = CognitiveLoop.new(self, session)
270
- messages = [{ 'role' => 'user', 'content' => build_reflect_prompt(session, act_result) }]
271
- result = loop.run_phase('reflect', reflect_system_prompt, messages, [])
272
-
273
- if result['content']
590
+ # Parse REFLECT response, handling code fences and nested JSON
591
+ def parse_reflect_json(content)
592
+ # Try direct parse first
593
+ JSON.parse(content)
594
+ rescue JSON::ParserError
595
+ # Try extracting from code fences
596
+ if content =~ /```(?:json)?\s*\n?(.*?)\n?\s*```/m
274
597
  begin
275
- JSON.parse(result['content'])
598
+ return JSON.parse($1)
276
599
  rescue JSON::ParserError
277
- { 'confidence' => 0.0, 'raw' => result['content'] }
600
+ # fall through
278
601
  end
279
- else
280
- { 'confidence' => 0.0, 'error' => result['error'] || 'no content' }
281
602
  end
603
+ # Last resort: confidence 0.0 with raw content preserved
604
+ { 'confidence' => 0.0, 'raw' => content }
282
605
  end
283
606
 
284
607
  # ---- NEXT CYCLE ----
@@ -287,12 +610,12 @@ module KairosMcp
287
610
  # checkpoint_due? is checked BEFORE reaching [checkpoint] (in run_act_reflect).
288
611
  # When the user approves at [checkpoint], we always proceed to the next cycle.
289
612
  def run_next_cycle(session)
290
- mandate = Autonomos::Mandate.load(session.mandate_id)
613
+ mandate = ::Autonomos::Mandate.load(session.mandate_id)
291
614
 
292
615
  # Check termination conditions
293
- term_reason = Autonomos::Mandate.check_termination(mandate)
616
+ term_reason = ::Autonomos::Mandate.check_termination(mandate)
294
617
  if term_reason
295
- Autonomos::Mandate.update_status(session.mandate_id, 'terminated')
618
+ ::Autonomos::Mandate.update_status(session.mandate_id, 'terminated')
296
619
  session.update_state('terminated')
297
620
  session.save
298
621
  return text_content(JSON.generate({
@@ -355,31 +678,29 @@ module KairosMcp
355
678
 
356
679
  # M4: Loop detection using decision_payload['summary'] as canonical gap
357
680
  # description (matches autonomos_loop's approach).
358
- # Single-session-per-mandate assumed (no concurrent mandate writes).
359
- def check_loop_detection(session, _orient_result, decision_payload)
360
- mandate = Autonomos::Mandate.load(session.mandate_id)
681
+ # Accepts optional in-memory mandate to avoid stale reads in autonomous mode.
682
+ def check_loop_detection(session, _orient_result, decision_payload, mandate_override: nil)
683
+ mandate = mandate_override || ::Autonomos::Mandate.load(session.mandate_id)
361
684
  return nil unless mandate
362
685
 
363
- # Use decision summary as gap description (same source as proposal)
364
686
  gap_desc = decision_payload['summary'] || 'unknown'
365
687
  recent_gaps = Array(mandate[:recent_gap_descriptions])
366
688
  recent_gaps_updated = (recent_gaps + [gap_desc]).last(3)
367
689
 
368
690
  proposal = MandateAdapter.to_mandate_proposal(decision_payload)
369
691
 
370
- if Autonomos::Mandate.loop_detected?(proposal, recent_gaps)
371
- # Single save: update both status and gap history atomically
692
+ if ::Autonomos::Mandate.loop_detected?(proposal, recent_gaps)
372
693
  mandate[:status] = 'terminated'
373
694
  mandate[:recent_gap_descriptions] = recent_gaps_updated
374
- Autonomos::Mandate.save(session.mandate_id, mandate)
695
+ ::Autonomos::Mandate.save(session.mandate_id, mandate)
375
696
  session.update_state('terminated')
376
697
  session.save
377
698
  return true
378
699
  end
379
700
 
380
- # Update gap history even if no loop detected
701
+ # Update gap history in-memory and on disk
381
702
  mandate[:recent_gap_descriptions] = recent_gaps_updated
382
- Autonomos::Mandate.save(session.mandate_id, mandate)
703
+ ::Autonomos::Mandate.save(session.mandate_id, mandate)
383
704
  nil
384
705
  rescue StandardError => e
385
706
  warn "[agent] Loop detection failed: #{e.message}"
@@ -390,7 +711,7 @@ module KairosMcp
390
711
 
391
712
  def record_agent_cycle(session, decision_payload, act_result, reflect_result)
392
713
  evaluation = MandateAdapter.reflect_to_evaluation(reflect_result)
393
- Autonomos::Mandate.record_cycle(
714
+ ::Autonomos::Mandate.record_cycle(
394
715
  session.mandate_id,
395
716
  cycle_id: "#{session.session_id}_cycle#{session.cycle_number}",
396
717
  evaluation: evaluation
@@ -472,6 +793,7 @@ module KairosMcp
472
793
  # Build a filtered tool catalog for DECIDE prompt.
473
794
  # Uses session's InvocationContext.allowed? for blacklist/whitelist
474
795
  # consistency with the ACT phase execution policy.
796
+ # Includes parameter schemas so DECIDE LLM generates correct tool_arguments.
475
797
  def build_tool_catalog(session)
476
798
  return "(no registry available)" unless @registry
477
799
 
@@ -480,12 +802,40 @@ module KairosMcp
480
802
  tools = tools.reject { |t| ctx && !ctx.allowed?(t[:name]) } if ctx
481
803
 
482
804
  tools.map { |t|
483
- required = extract_required_params(t[:inputSchema])
484
- params_str = required.empty? ? '' : " (params: #{required.join(', ')})"
485
- "- **#{t[:name]}**#{params_str}: #{t[:description]}"
805
+ format_tool_entry(t)
486
806
  }.join("\n")
487
807
  end
488
808
 
809
+ # Format a single tool entry with parameter details for DECIDE.
810
+ def format_tool_entry(tool)
811
+ schema = tool[:inputSchema] || {}
812
+ required_names = extract_required_params(schema)
813
+ properties = schema[:properties] || schema['properties'] || {}
814
+
815
+ lines = ["- **#{tool[:name]}**: #{tool[:description]}"]
816
+
817
+ unless properties.empty?
818
+ req_parts = []
819
+ opt_parts = []
820
+ properties.each do |param_name, param_def|
821
+ pname = param_name.to_s
822
+ ptype = param_def['type'] || param_def[:type] || '?'
823
+ pdesc = param_def['description'] || param_def[:description]
824
+ short_desc = pdesc ? pdesc.to_s[0..60] : nil
825
+ entry = short_desc ? "#{pname} (#{ptype}: #{short_desc})" : "#{pname} (#{ptype})"
826
+ if required_names.include?(pname)
827
+ req_parts << entry
828
+ else
829
+ opt_parts << entry
830
+ end
831
+ end
832
+ lines << " Required: #{req_parts.join(', ')}" unless req_parts.empty?
833
+ lines << " Optional: #{opt_parts.join(', ')}" unless opt_parts.empty?
834
+ end
835
+
836
+ lines.join("\n")
837
+ end
838
+
489
839
  # Extract required parameter names from an inputSchema hash.
490
840
  def extract_required_params(schema)
491
841
  return [] unless schema.is_a?(Hash)
@@ -53,7 +53,7 @@ module KairosMcp
53
53
 
54
54
  # Update mandate status
55
55
  begin
56
- Autonomos::Mandate.update_status(session.mandate_id, 'interrupted')
56
+ ::Autonomos::Mandate.update_status(session.mandate_id, 'interrupted')
57
57
  rescue StandardError
58
58
  # Non-fatal
59
59
  end
@@ -19,6 +19,8 @@ module Autonomos
19
19
 
20
20
  RISK_BUDGETS = %w[low medium].freeze
21
21
 
22
+ class LockError < StandardError; end
23
+
22
24
  class << self
23
25
  def create(goal_name:, goal_hash:, max_cycles:, checkpoint_every:, risk_budget:)
24
26
  validate_params!(max_cycles, checkpoint_every, risk_budget)
@@ -48,21 +50,43 @@ module Autonomos
48
50
 
49
51
  def load(mandate_id)
50
52
  validate_id!(mandate_id)
51
- mandates_dir = Autonomos.storage_path('mandates')
52
- path = File.join(mandates_dir, "#{mandate_id}.json")
53
+ path = mandate_path(mandate_id)
53
54
  return nil unless File.exist?(path)
54
55
 
55
56
  JSON.parse(File.read(path), symbolize_names: true)
56
57
  end
57
58
 
59
+ # Alias for clarity when reloading inside with_lock
60
+ def reload(mandate_id)
61
+ load(mandate_id)
62
+ end
63
+
58
64
  def save(mandate_id, mandate)
59
65
  validate_id!(mandate_id)
60
- mandates_dir = Autonomos.storage_path('mandates')
66
+ path = mandate_path(mandate_id)
61
67
  mandate[:updated_at] = Time.now.iso8601
62
- File.write(
63
- File.join(mandates_dir, "#{mandate_id}.json"),
64
- JSON.pretty_generate(mandate)
65
- )
68
+ # Atomic write via tmp+rename
69
+ tmp = "#{path}.tmp.#{$$}.#{Thread.current.object_id}"
70
+ File.write(tmp, JSON.pretty_generate(mandate))
71
+ File.rename(tmp, path)
72
+ end
73
+
74
+ # Single-writer lock for autonomous batch execution.
75
+ # Yields the loaded mandate; caller must use reload() to refresh after internal saves.
76
+ def with_lock(mandate_id)
77
+ validate_id!(mandate_id)
78
+ lock_path = mandate_path(mandate_id) + '.lock'
79
+ File.open(lock_path, File::CREAT | File::RDWR) do |f|
80
+ unless f.flock(File::LOCK_EX | File::LOCK_NB)
81
+ raise LockError, "Mandate #{mandate_id} is locked by another process"
82
+ end
83
+ begin
84
+ mandate = load(mandate_id)
85
+ yield mandate
86
+ ensure
87
+ f.flock(File::LOCK_UN)
88
+ end
89
+ end
66
90
  end
67
91
 
68
92
  def update_status(mandate_id, new_status)
@@ -179,6 +203,11 @@ module Autonomos
179
203
  "mnd_#{Time.now.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(3)}"
180
204
  end
181
205
 
206
+ def mandate_path(mandate_id)
207
+ mandates_dir = Autonomos.storage_path('mandates')
208
+ File.join(mandates_dir, "#{mandate_id}.json")
209
+ end
210
+
182
211
  def validate_id!(mandate_id)
183
212
  unless mandate_id.to_s.match?(/\A[\w\-]+\z/)
184
213
  raise ArgumentError, 'Invalid mandate_id: must contain only word characters and hyphens'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.7.0
4
+ version: 3.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-29 00:00:00.000000000 Z
11
+ date: 2026-03-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -450,7 +450,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
450
450
  - !ruby/object:Gem::Version
451
451
  version: '0'
452
452
  requirements: []
453
- rubygems_version: 3.3.26
453
+ rubygems_version: 3.5.22
454
454
  signing_key:
455
455
  specification_version: 4
456
456
  summary: KairosChain - Self-referential MCP server for auditable skill self-management