lex-agentic-executive 0.1.12 → 0.2.1

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.
Files changed (25) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/lib/legion/extensions/agentic/executive/goal_management/client.rb +23 -0
  4. data/lib/legion/extensions/agentic/executive/goal_management/helpers/decomposer.rb +126 -0
  5. data/lib/legion/extensions/agentic/executive/goal_management/helpers/feedback_listener.rb +63 -0
  6. data/lib/legion/extensions/agentic/executive/goal_management/helpers/goal.rb +41 -12
  7. data/lib/legion/extensions/agentic/executive/goal_management/helpers/goal_engine.rb +99 -10
  8. data/lib/legion/extensions/agentic/executive/goal_management/helpers/goal_persistence.rb +145 -0
  9. data/lib/legion/extensions/agentic/executive/goal_management/helpers/task_dispatcher.rb +109 -0
  10. data/lib/legion/extensions/agentic/executive/goal_management/runners/goal_management.rb +96 -36
  11. data/lib/legion/extensions/agentic/executive/goal_management.rb +3 -0
  12. data/lib/legion/extensions/agentic/executive/version.rb +1 -1
  13. data/lib/legion/extensions/agentic/executive/volition/client.rb +3 -1
  14. data/lib/legion/extensions/agentic/executive/volition/helpers/goal_bridge.rb +86 -0
  15. data/lib/legion/extensions/agentic/executive/volition/runners/volition.rb +13 -6
  16. data/lib/legion/extensions/agentic/executive/volition.rb +1 -0
  17. data/spec/integration/autonomous_goal_pipeline_spec.rb +107 -0
  18. data/spec/legion/extensions/agentic/executive/goal_management/helpers/decomposer_spec.rb +167 -0
  19. data/spec/legion/extensions/agentic/executive/goal_management/helpers/feedback_listener_spec.rb +104 -0
  20. data/spec/legion/extensions/agentic/executive/goal_management/helpers/goal_engine_spec.rb +32 -0
  21. data/spec/legion/extensions/agentic/executive/goal_management/helpers/goal_persistence_spec.rb +119 -0
  22. data/spec/legion/extensions/agentic/executive/goal_management/helpers/task_dispatcher_spec.rb +184 -0
  23. data/spec/legion/extensions/agentic/executive/volition/helpers/goal_bridge_spec.rb +58 -0
  24. data/spec/legion/extensions/agentic/executive/volition/runners/volition_spec.rb +26 -0
  25. metadata +12 -1
@@ -17,10 +17,10 @@ module Legion
17
17
  )
18
18
 
19
19
  new_intentions = Helpers::DriveSynthesizer.generate_intentions(drives, cognitive_state: cognitive_state)
20
- pushed = 0
20
+ pushed_intentions = []
21
21
  new_intentions.each do |intention|
22
- result = intention_stack.push(intention)
23
- pushed += 1 if result == :pushed
22
+ outcome = intention_stack.push(intention)
23
+ pushed_intentions << intention if outcome == :pushed
24
24
  end
25
25
 
26
26
  expired = intention_stack.decay_all
@@ -28,18 +28,25 @@ module Legion
28
28
  current = intention_stack.top
29
29
  proactive = evaluate_proactive_outreach(tick_results, bond_state)
30
30
 
31
- log.debug "[volition] drives=#{format_drives(drives)} pushed=#{pushed} expired=#{expired} " \
31
+ log.debug "[volition] drives=#{format_drives(drives)} pushed=#{pushed_intentions.size} expired=#{expired} " \
32
32
  "active=#{intention_stack.active_count} top=#{current&.dig(:goal)}"
33
33
 
34
- {
34
+ result = {
35
35
  drives: drives,
36
36
  dominant_drive: dominant,
37
- new_intentions: pushed,
37
+ new_intentions: pushed_intentions.map { |i| format_intention(i) },
38
38
  expired: expired,
39
39
  active_intentions: intention_stack.active_count,
40
40
  current_intention: format_intention(current),
41
41
  proactive_outreach: proactive
42
42
  }
43
+
44
+ if defined?(@goal_bridge) && @goal_bridge
45
+ bridge_result = @goal_bridge.bridge_intentions(pushed_intentions)
46
+ result[:bridge_result] = bridge_result
47
+ end
48
+
49
+ result
43
50
  end
44
51
 
45
52
  def current_intention(**)
@@ -5,6 +5,7 @@ require 'legion/extensions/agentic/executive/volition/helpers/constants'
5
5
  require 'legion/extensions/agentic/executive/volition/helpers/intention'
6
6
  require 'legion/extensions/agentic/executive/volition/helpers/intention_stack'
7
7
  require 'legion/extensions/agentic/executive/volition/helpers/drive_synthesizer'
8
+ require 'legion/extensions/agentic/executive/volition/helpers/goal_bridge'
8
9
  require 'legion/extensions/agentic/executive/volition/runners/volition'
9
10
  require 'legion/extensions/agentic/executive/volition/client'
10
11
 
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'Autonomous Goal Pipeline Integration' do
6
+ let(:goal_client) { Legion::Extensions::Agentic::Executive::GoalManagement::Client.new }
7
+ let(:bridge) do
8
+ Legion::Extensions::Agentic::Executive::Volition::Helpers::GoalBridge.new(
9
+ goal_client: goal_client
10
+ )
11
+ end
12
+ let(:volition_client) do
13
+ Legion::Extensions::Agentic::Executive::Volition::Client.new(
14
+ stack: Legion::Extensions::Agentic::Executive::Volition::Helpers::IntentionStack.new,
15
+ goal_bridge: bridge
16
+ )
17
+ end
18
+
19
+ let(:tick_results) do
20
+ { working_memory_integration: { curiosity: { intensity: 0.9, count: 5 } } }
21
+ end
22
+ let(:cognitive_state) do
23
+ { health: 0.6, pending_goals: 0, arousal: 0.5, gut: { signal: :explore },
24
+ confidence: 0.4, pending_questions: 3, peer_interactions: 0,
25
+ trust: { avg_composite: 0.5 } }
26
+ end
27
+ let(:bond_state) { {} }
28
+
29
+ it 'flows from intention through goal to dispatch-ready state' do
30
+ # Step 1: Form intentions (triggers goal bridge)
31
+ volition_result = volition_client.form_intentions(
32
+ tick_results: tick_results, cognitive_state: cognitive_state, bond_state: bond_state
33
+ )
34
+ expect(volition_result[:bridge_result][:bridged]).to be >= 1
35
+
36
+ # Step 2: Verify goals were created
37
+ goal_ids = volition_result[:bridge_result][:goal_ids]
38
+ expect(goal_ids).not_to be_empty
39
+
40
+ # Step 3: Auto-decompose the first goal
41
+ first_goal_id = goal_ids.first
42
+ goal_client.activate_goal(goal_id: first_goal_id)
43
+ decomp = goal_client.auto_decompose_goal(goal_id: first_goal_id, strategy: :heuristic)
44
+ expect(decomp[:success]).to be true
45
+
46
+ # Step 4: Verify goal tree has children
47
+ tree = goal_client.get_goal_tree(goal_id: first_goal_id)
48
+ expect(tree[:tree]).not_to be_nil
49
+
50
+ # Step 5: Check goal status shows goals exist
51
+ status = goal_client.goal_status
52
+ expect(status[:total]).to be >= 1
53
+ end
54
+
55
+ it 'updates goal on simulated task completion' do
56
+ # Create and activate a leaf goal with task assignment
57
+ add_result = goal_client.add_goal(
58
+ content: 'simple leaf goal', domain: :general, priority: 0.5,
59
+ parent_id: nil, deadline: nil
60
+ )
61
+ goal_id = add_result[:goal][:id]
62
+ goal_client.activate_goal(goal_id: goal_id)
63
+
64
+ engine = goal_client.send(:engine)
65
+ goal = engine.goals[goal_id]
66
+ goal.assign_task!(task_id: 'task-sim-001', runner_mapping: { runner_class: 'Test', function: :test })
67
+
68
+ # Simulate task completion
69
+ update = engine.update_from_task_event(task_id: 'task-sim-001', status: 'task.completed')
70
+ expect(update[:found]).to be true
71
+ expect(update[:new_status]).to eq(:completed)
72
+ expect(goal.progress).to eq(1.0)
73
+ end
74
+
75
+ it 'blocks goal on simulated task failure' do
76
+ add_result = goal_client.add_goal(
77
+ content: 'failing goal', domain: :general, priority: 0.5,
78
+ parent_id: nil, deadline: nil
79
+ )
80
+ goal_id = add_result[:goal][:id]
81
+ goal_client.activate_goal(goal_id: goal_id)
82
+
83
+ engine = goal_client.send(:engine)
84
+ goal = engine.goals[goal_id]
85
+ goal.assign_task!(task_id: 'task-fail-001', runner_mapping: { runner_class: 'Test', function: :test })
86
+
87
+ update = engine.update_from_task_event(task_id: 'task-fail-001', status: 'task.exception')
88
+ expect(update[:found]).to be true
89
+ expect(update[:new_status]).to eq(:blocked)
90
+ end
91
+
92
+ it 'reprioritizes goals on urgency signal' do
93
+ goal_client.add_goal(content: 'safety issue', domain: :safety, priority: 0.4, parent_id: nil, deadline: nil)
94
+ goal_client.add_goal(content: 'general task', domain: :general, priority: 0.4, parent_id: nil, deadline: nil)
95
+
96
+ # Activate both
97
+ engine = goal_client.send(:engine)
98
+ engine.goals.each_value(&:activate!)
99
+
100
+ result = engine.reprioritize!(signal: { domain: :safety, urgency: :critical })
101
+ expect(result[:adjusted]).to eq(1)
102
+
103
+ safety = engine.goals.values.find { |g| g.domain == :safety }
104
+ general = engine.goals.values.find { |g| g.domain == :general }
105
+ expect(safety.priority).to be > general.priority
106
+ end
107
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::Decomposer do
4
+ describe '.decompose' do
5
+ let(:goal_hash) do
6
+ { id: 'goal-001', content: 'fix degraded safety monitor', domain: :safety, priority: 0.7, status: :active }
7
+ end
8
+
9
+ context 'with heuristic strategy' do
10
+ it 'returns sub-goals array' do
11
+ result = described_class.decompose(goal: goal_hash, strategy: :heuristic)
12
+ expect(result[:success]).to be true
13
+ expect(result[:sub_goals]).to be_an(Array)
14
+ expect(result[:sub_goals]).not_to be_empty
15
+ expect(result[:sub_goals].first).to have_key(:content)
16
+ expect(result[:sub_goals].first).to have_key(:domain)
17
+ end
18
+
19
+ it 'preserves parent domain' do
20
+ result = described_class.decompose(goal: goal_hash, strategy: :heuristic)
21
+ result[:sub_goals].each { |sg| expect(sg[:domain]).to eq(:safety) }
22
+ end
23
+
24
+ it 'reports heuristic as strategy_used' do
25
+ result = described_class.decompose(goal: goal_hash, strategy: :heuristic)
26
+ expect(result[:strategy_used]).to eq(:heuristic)
27
+ end
28
+ end
29
+
30
+ context 'with llm strategy when llm unavailable' do
31
+ it 'falls back to heuristic' do
32
+ result = described_class.decompose(goal: goal_hash, strategy: :llm)
33
+ expect(result[:success]).to be true
34
+ expect(result[:strategy_used]).to eq(:heuristic)
35
+ end
36
+
37
+ it 'parses native hash responses from Legion::LLM.chat' do
38
+ llm = Module.new
39
+ llm.define_singleton_method(:chat) do |message:, **|
40
+ raise 'missing prompt' if message.to_s.empty?
41
+
42
+ { content: '[{"content":"inspect controls","domain":"safety","priority":0.8}]' }
43
+ end
44
+ stub_const('Legion::LLM', llm)
45
+
46
+ result = described_class.decompose(goal: goal_hash, strategy: :llm)
47
+
48
+ expect(result[:strategy_used]).to eq(:llm)
49
+ expect(result[:sub_goals].first[:content]).to eq('inspect controls')
50
+ end
51
+ end
52
+
53
+ context 'with default strategy' do
54
+ it 'uses heuristic when strategy is omitted' do
55
+ result = described_class.decompose(goal: goal_hash)
56
+ expect(result[:success]).to be true
57
+ expect(result[:strategy_used]).to eq(:heuristic)
58
+ end
59
+ end
60
+ end
61
+
62
+ describe '.decompose_heuristic' do
63
+ context 'with a known domain' do
64
+ it 'uses the safety domain template' do
65
+ goal = { content: 'monitor failure', domain: :safety, priority: 0.8 }
66
+ steps = described_class.decompose_heuristic(goal)
67
+ expect(steps.size).to eq(3)
68
+ expect(steps.map { |s| s[:content] }).to all(be_a(String))
69
+ end
70
+
71
+ it 'uses the cognition domain template' do
72
+ goal = { content: 'knowledge gap', domain: :cognition, priority: 0.6 }
73
+ steps = described_class.decompose_heuristic(goal)
74
+ expect(steps.size).to eq(3)
75
+ end
76
+
77
+ it 'uses the perception domain template' do
78
+ goal = { content: 'sensor drift', domain: :perception, priority: 0.5 }
79
+ steps = described_class.decompose_heuristic(goal)
80
+ expect(steps.size).to eq(3)
81
+ end
82
+ end
83
+
84
+ context 'with an unknown domain' do
85
+ it 'falls back to default four-step template' do
86
+ goal = { content: 'unknown task', domain: :general, priority: 0.5 }
87
+ steps = described_class.decompose_heuristic(goal)
88
+ expect(steps.size).to eq(4)
89
+ end
90
+ end
91
+
92
+ it 'sets priority from parent goal' do
93
+ goal = { content: 'some task', domain: :safety, priority: 0.9 }
94
+ steps = described_class.decompose_heuristic(goal)
95
+ steps.each { |s| expect(s[:priority]).to eq(0.9) }
96
+ end
97
+
98
+ it 'defaults priority to 0.5 when not provided' do
99
+ goal = { content: 'some task', domain: :general }
100
+ steps = described_class.decompose_heuristic(goal)
101
+ steps.each { |s| expect(s[:priority]).to eq(0.5) }
102
+ end
103
+ end
104
+
105
+ describe '.decompose_by_domain' do
106
+ it 'returns nil for unknown domain' do
107
+ result = described_class.decompose_by_domain('content', :unknown)
108
+ expect(result).to be_nil
109
+ end
110
+
111
+ it 'returns array of strings for known domain' do
112
+ result = described_class.decompose_by_domain('test content', :safety)
113
+ expect(result).to be_an(Array)
114
+ expect(result).to all(be_a(String))
115
+ end
116
+ end
117
+
118
+ describe '.default_steps' do
119
+ it 'returns four steps' do
120
+ steps = described_class.default_steps('do something')
121
+ expect(steps.size).to eq(4)
122
+ end
123
+
124
+ it 'interpolates content into each step' do
125
+ steps = described_class.default_steps('repair the system')
126
+ expect(steps).to all(include('repair the system'))
127
+ end
128
+ end
129
+
130
+ describe '.parse_sub_goals' do
131
+ it 'parses valid JSON array' do
132
+ json = '[{"content":"step one","domain":"safety","priority":0.8}]'
133
+ result = described_class.parse_sub_goals(json, :safety)
134
+ expect(result).to be_an(Array)
135
+ expect(result.first[:content]).to eq('step one')
136
+ expect(result.first[:domain]).to eq(:safety)
137
+ expect(result.first[:priority]).to eq(0.8)
138
+ end
139
+
140
+ it 'returns nil for invalid JSON' do
141
+ expect(described_class.parse_sub_goals('not json', :general)).to be_nil
142
+ end
143
+
144
+ it 'returns nil for empty array' do
145
+ expect(described_class.parse_sub_goals('[]', :general)).to be_nil
146
+ end
147
+
148
+ it 'strips markdown code fences' do
149
+ json = "```json\n[{\"content\":\"step\",\"domain\":\"general\",\"priority\":0.5}]\n```"
150
+ result = described_class.parse_sub_goals(json, :general)
151
+ expect(result).not_to be_nil
152
+ expect(result.first[:content]).to eq('step')
153
+ end
154
+
155
+ it 'clamps priority to 0.0..1.0' do
156
+ json = '[{"content":"x","priority":2.5}]'
157
+ result = described_class.parse_sub_goals(json, :general)
158
+ expect(result.first[:priority]).to eq(1.0)
159
+ end
160
+
161
+ it 'uses fallback domain when sub-goal omits domain' do
162
+ json = '[{"content":"x"}]'
163
+ result = described_class.parse_sub_goals(json, :cognition)
164
+ expect(result.first[:domain]).to eq(:cognition)
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::FeedbackListener do
4
+ let(:engine) { Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::GoalEngine.new }
5
+ let(:listener) { described_class.new(engine: engine) }
6
+
7
+ describe '#handle_task_event' do
8
+ before do
9
+ result = engine.add_goal(content: 'test goal', domain: :general, priority: 0.5, parent_id: nil, deadline: nil)
10
+ @goal_id = result[:goal][:id]
11
+ goal = engine.goals[@goal_id]
12
+ goal.activate!
13
+ goal.instance_variable_set(:@task_id, 'task-123')
14
+ end
15
+
16
+ it 'completes goal on task.completed' do
17
+ result = listener.handle_task_event(task_id: 'task-123', status: 'task.completed')
18
+ expect(result[:found]).to be true
19
+ expect(result[:new_status]).to eq(:completed)
20
+ end
21
+
22
+ it 'blocks goal on task.exception' do
23
+ result = listener.handle_task_event(task_id: 'task-123', status: 'task.exception')
24
+ expect(result[:found]).to be true
25
+ expect(result[:new_status]).to eq(:blocked)
26
+ end
27
+
28
+ it 'blocks goal on task.failed' do
29
+ result = listener.handle_task_event(task_id: 'task-123', status: 'task.failed')
30
+ expect(result[:found]).to be true
31
+ expect(result[:new_status]).to eq(:blocked)
32
+ end
33
+
34
+ it 'returns not found for unknown task_id' do
35
+ result = listener.handle_task_event(task_id: 'unknown', status: 'task.completed')
36
+ expect(result[:found]).to be false
37
+ end
38
+
39
+ it 'passes result through on failure status' do
40
+ result = listener.handle_task_event(task_id: 'task-123', status: 'task.failed', result: 'timeout')
41
+ expect(result[:error]).to eq('timeout')
42
+ end
43
+ end
44
+
45
+ describe '#start_listening' do
46
+ it 'registers event listeners when Legion::Events is available' do
47
+ events_spy = Class.new do
48
+ attr_reader :registered
49
+
50
+ def initialize
51
+ @registered = []
52
+ end
53
+
54
+ def on(event, &block)
55
+ @registered << { event: event, block: block }
56
+ end
57
+ end.new
58
+ stub_const('Legion::Events', events_spy)
59
+ listener.start_listening
60
+ event_names = events_spy.registered.map { |r| r[:event] }
61
+ expect(event_names).to include('task.completed')
62
+ expect(event_names).to include('task.failed')
63
+ end
64
+
65
+ it 'sets listening? to true after start' do
66
+ events_spy = Class.new do
67
+ def on(_event, &); end
68
+ end.new
69
+ stub_const('Legion::Events', events_spy)
70
+ listener.start_listening
71
+ expect(listener.listening?).to be true
72
+ end
73
+
74
+ it 'does not register twice if called again' do
75
+ events_spy = Class.new do
76
+ attr_reader :registered
77
+
78
+ def initialize
79
+ @registered = []
80
+ end
81
+
82
+ def on(event, &block)
83
+ @registered << { event: event, block: block }
84
+ end
85
+ end.new
86
+ stub_const('Legion::Events', events_spy)
87
+ listener.start_listening
88
+ listener.start_listening
89
+ expect(events_spy.registered.size).to eq(2)
90
+ end
91
+
92
+ it 'does nothing when Legion::Events is not defined' do
93
+ hide_const('Legion::Events') if defined?(Legion::Events)
94
+ expect { listener.start_listening }.not_to raise_error
95
+ expect(listener.listening?).to be false
96
+ end
97
+ end
98
+
99
+ describe '#listening?' do
100
+ it 'returns false before start_listening' do
101
+ expect(listener.listening?).to be false
102
+ end
103
+ end
104
+ end
@@ -318,6 +318,38 @@ RSpec.describe Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::
318
318
  end
319
319
  end
320
320
 
321
+ describe '#reprioritize!' do
322
+ let(:engine) { described_class.new }
323
+
324
+ before do
325
+ result = engine.add_goal(content: 'low priority task', domain: :general, priority: 0.3, parent_id: nil,
326
+ deadline: nil)
327
+ engine.activate_goal(goal_id: result[:goal][:id])
328
+ result = engine.add_goal(content: 'safety monitor degraded', domain: :safety, priority: 0.5, parent_id: nil,
329
+ deadline: nil)
330
+ engine.activate_goal(goal_id: result[:goal][:id])
331
+ end
332
+
333
+ it 'boosts goals matching the signal domain' do
334
+ safety_goal = engine.active_goals.find { |g| g.domain == :safety }
335
+ old_priority = safety_goal.priority
336
+ engine.reprioritize!(signal: { domain: :safety, urgency: :critical })
337
+ expect(safety_goal.priority).to be > old_priority
338
+ end
339
+
340
+ it 'does not affect goals in other domains' do
341
+ general_goal = engine.active_goals.find { |g| g.domain == :general }
342
+ old_priority = general_goal.priority
343
+ engine.reprioritize!(signal: { domain: :safety, urgency: :critical })
344
+ expect(general_goal.priority).to eq(old_priority)
345
+ end
346
+
347
+ it 'handles unknown domains gracefully' do
348
+ result = engine.reprioritize!(signal: { domain: :unknown, urgency: :low })
349
+ expect(result[:adjusted]).to eq(0)
350
+ end
351
+ end
352
+
321
353
  describe '#goal_report' do
322
354
  it 'reports total goal count' do
323
355
  3.times { engine.add_goal(content: 'goal') }
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::GoalPersistence do
4
+ let(:persistence) { described_class.new(namespace: 'test_goals') }
5
+
6
+ before do
7
+ stub_const('Legion::Cache', Class.new do
8
+ def self.connected? = true
9
+ def self.get(key) = (@store ||= {})[key]
10
+ def self.set(key, value, **_opts) = ((@store ||= {})[key] = value)
11
+ def self.set_sync(key, value, **_opts) = ((@store ||= {})[key] = value)
12
+ def self.delete(key, **_opts) = (@store ||= {}).delete(key)
13
+ def self.delete_sync(key) = (@store ||= {}).delete(key)
14
+ def self.flush = (@store = {})
15
+ end)
16
+ Legion::Cache.flush
17
+ end
18
+
19
+ describe '#save_goal / #load_goal' do
20
+ it 'round-trips a goal hash' do
21
+ goal_hash = { id: 'g-001', content: 'test', domain: :safety, priority: 0.7,
22
+ status: :active, progress: 0.0, sub_goal_ids: [] }
23
+ persistence.save_goal(goal_hash)
24
+ loaded = persistence.load_goal('g-001')
25
+ expect(loaded[:content]).to eq('test')
26
+ # JSON round-trip: symbol values become strings; Goal.from_h re-symbolizes via to_sym
27
+ expect(loaded[:domain].to_sym).to eq(:safety)
28
+ end
29
+
30
+ it 'returns nil for missing goal' do
31
+ expect(persistence.load_goal('nonexistent')).to be_nil
32
+ end
33
+
34
+ it 'updates the index on save' do
35
+ persistence.save_goal({ id: 'g-idx', content: 'indexed', domain: :general,
36
+ priority: 0.5, status: :proposed, progress: 0.0, sub_goal_ids: [] })
37
+ all = persistence.load_all
38
+ expect(all.keys).to include('g-idx')
39
+ end
40
+ end
41
+
42
+ describe '#save_all / #load_all' do
43
+ it 'round-trips multiple goals' do
44
+ goals = {
45
+ 'g-001' => { id: 'g-001', content: 'goal one', domain: :safety, priority: 0.5,
46
+ status: :active, progress: 0.0, sub_goal_ids: [] },
47
+ 'g-002' => { id: 'g-002', content: 'goal two', domain: :cognition, priority: 0.8,
48
+ status: :proposed, progress: 0.0, sub_goal_ids: [] }
49
+ }
50
+ persistence.save_all(goals)
51
+ loaded = persistence.load_all
52
+ expect(loaded.size).to eq(2)
53
+ expect(loaded['g-001'][:content]).to eq('goal one')
54
+ end
55
+
56
+ it 'returns empty hash when nothing saved' do
57
+ expect(persistence.load_all).to eq({})
58
+ end
59
+ end
60
+
61
+ describe '#delete_goal' do
62
+ it 'removes a goal from cache' do
63
+ persistence.save_goal({ id: 'g-del', content: 'delete me', domain: :general,
64
+ priority: 0.5, status: :active, progress: 0.0, sub_goal_ids: [] })
65
+ persistence.delete_goal('g-del')
66
+ expect(persistence.load_goal('g-del')).to be_nil
67
+ end
68
+
69
+ it 'removes the goal id from the index' do
70
+ persistence.save_goal({ id: 'g-rm', content: 'remove from index', domain: :general,
71
+ priority: 0.5, status: :active, progress: 0.0, sub_goal_ids: [] })
72
+ persistence.delete_goal('g-rm')
73
+ all = persistence.load_all
74
+ expect(all.keys).not_to include('g-rm')
75
+ end
76
+ end
77
+
78
+ describe '#save_bridge_state / #load_bridge_state' do
79
+ it 'round-trips bridge tracking hash' do
80
+ # JSON round-trip with symbolize_names: true turns string keys into symbols
81
+ state = { 'curiosity:cognition:explore gaps' => 'g-001' }
82
+ persistence.save_bridge_state(state)
83
+ loaded = persistence.load_bridge_state
84
+ expect(loaded.values.first).to eq('g-001')
85
+ end
86
+
87
+ it 'returns empty hash when nothing saved' do
88
+ expect(persistence.load_bridge_state).to eq({})
89
+ end
90
+ end
91
+
92
+ describe 'when cache unavailable' do
93
+ before { allow(Legion::Cache).to receive(:connected?).and_return(false) }
94
+
95
+ it 'returns nil on load_goal' do
96
+ expect(persistence.load_goal('g-001')).to be_nil
97
+ end
98
+
99
+ it 'returns false on save_goal' do
100
+ expect(persistence.save_goal({ id: 'g-001', content: 'x' })).to be false
101
+ end
102
+
103
+ it 'returns empty hash on load_all' do
104
+ expect(persistence.load_all).to eq({})
105
+ end
106
+
107
+ it 'returns empty hash on load_bridge_state' do
108
+ expect(persistence.load_bridge_state).to eq({})
109
+ end
110
+
111
+ it 'returns false on save_bridge_state' do
112
+ expect(persistence.save_bridge_state({ 'k' => 'v' })).to be false
113
+ end
114
+
115
+ it 'returns false on delete_goal' do
116
+ expect(persistence.delete_goal('g-001')).to be false
117
+ end
118
+ end
119
+ end