lex-agentic-executive 0.1.11 → 0.2.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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +1 -1
  4. data/lib/legion/extensions/agentic/executive/goal_management/client.rb +23 -0
  5. data/lib/legion/extensions/agentic/executive/goal_management/helpers/decomposer.rb +107 -0
  6. data/lib/legion/extensions/agentic/executive/goal_management/helpers/feedback_listener.rb +63 -0
  7. data/lib/legion/extensions/agentic/executive/goal_management/helpers/goal.rb +41 -12
  8. data/lib/legion/extensions/agentic/executive/goal_management/helpers/goal_engine.rb +99 -10
  9. data/lib/legion/extensions/agentic/executive/goal_management/helpers/goal_persistence.rb +145 -0
  10. data/lib/legion/extensions/agentic/executive/goal_management/helpers/task_dispatcher.rb +109 -0
  11. data/lib/legion/extensions/agentic/executive/goal_management/runners/goal_management.rb +96 -36
  12. data/lib/legion/extensions/agentic/executive/goal_management.rb +3 -0
  13. data/lib/legion/extensions/agentic/executive/version.rb +1 -1
  14. data/lib/legion/extensions/agentic/executive/volition/client.rb +3 -1
  15. data/lib/legion/extensions/agentic/executive/volition/helpers/goal_bridge.rb +86 -0
  16. data/lib/legion/extensions/agentic/executive/volition/runners/volition.rb +13 -6
  17. data/lib/legion/extensions/agentic/executive/volition.rb +1 -0
  18. data/lib/legion/extensions/agentic/executive.rb +12 -0
  19. data/spec/integration/autonomous_goal_pipeline_spec.rb +107 -0
  20. data/spec/legion/extensions/agentic/executive/goal_management/helpers/decomposer_spec.rb +152 -0
  21. data/spec/legion/extensions/agentic/executive/goal_management/helpers/feedback_listener_spec.rb +104 -0
  22. data/spec/legion/extensions/agentic/executive/goal_management/helpers/goal_engine_spec.rb +32 -0
  23. data/spec/legion/extensions/agentic/executive/goal_management/helpers/goal_persistence_spec.rb +119 -0
  24. data/spec/legion/extensions/agentic/executive/goal_management/helpers/task_dispatcher_spec.rb +176 -0
  25. data/spec/legion/extensions/agentic/executive/volition/helpers/goal_bridge_spec.rb +58 -0
  26. data/spec/legion/extensions/agentic/executive/volition/runners/volition_spec.rb +26 -0
  27. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60248390bb61f50186d12e7924274bf70795420a5a76142362dd8fcc127b34e6
4
- data.tar.gz: bf02ad4761edd9eb3abf744a57c4c8beeb5a7068be38298b765c40844730d80d
3
+ metadata.gz: aaa8f955fd551b961fd735e5bbf9a0dc33b54134b4d5e2ab1506221687aa3195
4
+ data.tar.gz: 862042eb5a340dcacce8ec8f2c7cb5f2b9501640ca3d0f39e3307ab69c87d571
5
5
  SHA512:
6
- metadata.gz: 143d738ad28317564cbc6d0ced1ee32c1d6d511fb5d5923c76ac4c16aa5649201b8388c7144e249de44cdced648e47072d40a239ffdb124b779af695667f4123
7
- data.tar.gz: 7c8c2a4223505aedb5b4f5a5e470b31ca0567660531201aa61e487f209e5345a8e9f16ed222c72d1ce5307d069622ae400064e56bde78898f57fd819c1afaed7
6
+ metadata.gz: 51cbce9e25c1edb78855eeb7304663855d5f3b4cfc0072b074476f16c230786ea9bd7dfff15c43f10a55558463642218835d3ed3e9f1fcc7a444fbcce1893a74
7
+ data.tar.gz: 69f0e5e91e4f98c9b2a46d518301674bedf0bedf7ee04adb4a8b5117c450afedf0ebbe8dc2eef73143a6773e96df5bf2a13e6cdf0ab9c4a590b97c4af821559a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-04-21
4
+ ### Added
5
+ - **Autonomous Goal-Setting Pipeline (G1-G5)** — GAIA can now convert intentions into goals, decompose them, dispatch execution, and receive feedback with no human intervention
6
+ - `Volition::Helpers::GoalBridge` — converts volition intentions into proposed goals via GoalManagement (G1)
7
+ - `GoalManagement::Helpers::Decomposer` — autonomous goal decomposition with domain-aware heuristic templates and optional LLM strategy (G2)
8
+ - `GoalManagement::Helpers::TaskDispatcher` — dispatches leaf goals to runners via `Legion::Runner.run` or Client instantiation, with per-domain argument builders and load-order guards (G3)
9
+ - `GoalEngine#reprioritize!` — event-driven priority adjustment with urgency-calibrated boost levels (G4)
10
+ - `GoalManagement::Helpers::FeedbackListener` — subscribes to `Legion::Events` for `task.completed`/`task.failed` and updates goal progress/status (G5)
11
+ - `GoalEngine#update_from_task_event` — thread-safe (Mutex) goal status update from task completion events
12
+ - `Goal#assign_task!` — associates a dispatched task_id and runner_mapping with a goal
13
+ - Auto-activation of proposed leaf goals before dispatch
14
+ - Auto-start of FeedbackListener on GoalManagement::Client initialization
15
+
16
+ ## [0.1.12] - 2026-04-15
17
+ ### Changed
18
+ - Set `mcp_tools?`, `mcp_tools_deferred?`, and `transport_required?` to `false` — internal cognitive pipeline extension
19
+
3
20
  ## [Unreleased]
4
21
 
5
22
  ### Fixed
data/README.md CHANGED
@@ -5,7 +5,7 @@ Domain consolidation gem for executive function, goal management, planning, and
5
5
  ## Overview
6
6
 
7
7
  **Gem**: `lex-agentic-executive`
8
- **Version**: 0.1.1
8
+ **Version**: 0.1.12
9
9
  **Namespace**: `Legion::Extensions::Agentic::Executive`
10
10
 
11
11
  ## Sub-Modules
@@ -2,7 +2,11 @@
2
2
 
3
3
  require 'legion/extensions/agentic/executive/goal_management/helpers/constants'
4
4
  require 'legion/extensions/agentic/executive/goal_management/helpers/goal'
5
+ require 'legion/extensions/agentic/executive/goal_management/helpers/goal_persistence'
5
6
  require 'legion/extensions/agentic/executive/goal_management/helpers/goal_engine'
7
+ require 'legion/extensions/agentic/executive/goal_management/helpers/task_dispatcher'
8
+ require 'legion/extensions/agentic/executive/goal_management/helpers/feedback_listener'
9
+ require 'legion/extensions/agentic/executive/goal_management/helpers/decomposer'
6
10
  require 'legion/extensions/agentic/executive/goal_management/runners/goal_management'
7
11
 
8
12
  module Legion
@@ -13,13 +17,32 @@ module Legion
13
17
  class Client
14
18
  include Runners::GoalManagement
15
19
 
20
+ LISTENER_MUTEX = Mutex.new
21
+ private_constant :LISTENER_MUTEX
22
+
23
+ @listener_started = false
24
+ class << self
25
+ attr_accessor :listener_started
26
+ end
27
+
16
28
  def initialize(**)
17
29
  @engine = Helpers::GoalEngine.new
30
+ @feedback_listener = Helpers::FeedbackListener.new(engine: @engine)
31
+ start_listener_once
18
32
  end
19
33
 
20
34
  private
21
35
 
22
36
  attr_reader :engine
37
+
38
+ def start_listener_once
39
+ LISTENER_MUTEX.synchronize do
40
+ return if self.class.listener_started
41
+
42
+ @feedback_listener.start_listening
43
+ self.class.listener_started = true
44
+ end
45
+ end
23
46
  end
24
47
  end
25
48
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Executive
7
+ module GoalManagement
8
+ module Helpers
9
+ module Decomposer
10
+ extend Legion::JSON::Helper if defined?(Legion::JSON::Helper)
11
+
12
+ module_function
13
+
14
+ def log
15
+ Legion::Logging
16
+ end
17
+
18
+ DOMAIN_TEMPLATES = {
19
+ safety: ->(c) { ["diagnose: #{c}", "implement fix for: #{c}", "verify health after: #{c}"] },
20
+ cognition: ->(c) { ["analyze gaps in: #{c}", "design approach for: #{c}", "validate: #{c}"] },
21
+ perception: ->(c) { ["observe current state of: #{c}", "identify patterns in: #{c}", "calibrate: #{c}"] }
22
+ }.freeze
23
+
24
+ def decompose(goal:, strategy: :heuristic)
25
+ sub_goals, strategy_used = case strategy
26
+ when :llm
27
+ result = decompose_with_llm(goal)
28
+ result ? [result, :llm] : [decompose_heuristic(goal), :heuristic]
29
+ else
30
+ [decompose_heuristic(goal), :heuristic]
31
+ end
32
+ log.info "[decomposer] decomposed goal=#{goal[:id]} strategy=#{strategy_used}"
33
+ { success: true, sub_goals: sub_goals, strategy_used: strategy_used }
34
+ rescue StandardError => e
35
+ log.error "Decomposer: #{e.message}"
36
+ { success: false, error: e.message }
37
+ end
38
+
39
+ def decompose_heuristic(goal)
40
+ content = goal[:content].to_s.downcase
41
+ domain = (goal[:domain] || :general).to_sym
42
+ steps = decompose_by_domain(content, domain) || default_steps(content)
43
+ steps.map { |step| { content: step, domain: domain, priority: goal[:priority] || 0.5 } }
44
+ end
45
+
46
+ def decompose_with_llm(goal)
47
+ return nil unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:chat)
48
+
49
+ prompt = build_decomposition_prompt(goal)
50
+ response = Legion::LLM.chat(
51
+ caller: { extension: 'lex-agentic-executive', operation: 'decompose' }
52
+ ).ask(prompt)
53
+ parse_sub_goals(response.content, goal[:domain])
54
+ rescue StandardError => e
55
+ log.error "Decomposer#decompose_with_llm: #{e.message}"
56
+ nil
57
+ end
58
+
59
+ def decompose_by_domain(content, domain)
60
+ template = DOMAIN_TEMPLATES[domain]
61
+ return nil unless template
62
+
63
+ template.call(content)
64
+ end
65
+
66
+ def default_steps(content)
67
+ [
68
+ "analyze current state of: #{content}",
69
+ "plan approach for: #{content}",
70
+ "execute: #{content}",
71
+ "verify result of: #{content}"
72
+ ]
73
+ end
74
+
75
+ def build_decomposition_prompt(goal)
76
+ <<~PROMPT
77
+ Decompose this goal into 2-5 concrete sub-goals. Return JSON array.
78
+ Goal: #{goal[:content]}
79
+ Domain: #{goal[:domain]}
80
+ Each sub-goal: {"content": "action description", "domain": "#{goal[:domain]}", "priority": 0.0-1.0}
81
+ Return ONLY the JSON array, no other text.
82
+ PROMPT
83
+ end
84
+
85
+ def parse_sub_goals(content, domain)
86
+ cleaned = content.gsub(/```(?:json)?\s*\n?/, '').strip
87
+ data = json_load(cleaned)
88
+ return nil unless data.is_a?(Array) && !data.empty?
89
+
90
+ data.map do |sg|
91
+ {
92
+ content: sg[:content].to_s,
93
+ domain: (sg[:domain] || domain).to_sym,
94
+ priority: (sg[:priority] || 0.5).to_f.clamp(0.0, 1.0)
95
+ }
96
+ end
97
+ rescue StandardError => e
98
+ log.error "Decomposer#parse_sub_goals: #{e.message}"
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Executive
7
+ module GoalManagement
8
+ module Helpers
9
+ class FeedbackListener
10
+ def initialize(engine:)
11
+ @engine = engine
12
+ @listening = false
13
+ end
14
+
15
+ def handle_task_event(task_id:, status:, result: nil)
16
+ update = @engine.update_from_task_event(task_id: task_id, status: status, result: result)
17
+ goal_id = update[:goal_id] if update.is_a?(Hash)
18
+ new_status = update[:new_status] if update.is_a?(Hash)
19
+ unhandled_status = update[:unhandled_status] if update.is_a?(Hash)
20
+ log.info "[feedback_listener] task event task_id=#{task_id} goal_id=#{goal_id} " \
21
+ "task_status=#{status} new_status=#{new_status} unhandled_status=#{unhandled_status}"
22
+ update
23
+ end
24
+
25
+ def start_listening
26
+ return if @listening
27
+ return unless defined?(Legion::Events)
28
+
29
+ Legion::Events.on('task.completed') do |event|
30
+ handle_task_event(
31
+ task_id: event[:task_id],
32
+ status: event[:status] || 'task.completed',
33
+ result: event[:result]
34
+ )
35
+ end
36
+
37
+ Legion::Events.on('task.failed') do |event|
38
+ handle_task_event(
39
+ task_id: event[:task_id],
40
+ status: event[:status] || 'task.failed',
41
+ result: event[:result]
42
+ )
43
+ end
44
+
45
+ @listening = true
46
+ end
47
+
48
+ def listening?
49
+ @listening
50
+ end
51
+
52
+ private
53
+
54
+ def log
55
+ Legion::Logging
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
+ require 'time'
4
5
 
5
6
  module Legion
6
7
  module Extensions
@@ -13,20 +14,40 @@ module Legion
13
14
 
14
15
  attr_reader :id, :content, :parent_id, :sub_goal_ids, :status,
15
16
  :priority, :progress, :domain, :deadline,
16
- :created_at, :updated_at
17
+ :created_at, :updated_at, :task_id, :runner_mapping
18
+
19
+ def self.from_h(hash)
20
+ goal = allocate
21
+ goal.instance_variable_set(:@id, hash[:id])
22
+ goal.instance_variable_set(:@content, hash[:content])
23
+ goal.instance_variable_set(:@parent_id, hash[:parent_id])
24
+ goal.instance_variable_set(:@sub_goal_ids, hash[:sub_goal_ids] || [])
25
+ goal.instance_variable_set(:@status, hash[:status]&.to_sym || :proposed)
26
+ goal.instance_variable_set(:@priority, hash[:priority]&.to_f || 0.5)
27
+ goal.instance_variable_set(:@progress, (hash[:progress] || 0.0).to_f)
28
+ goal.instance_variable_set(:@domain, hash[:domain]&.to_sym || :general)
29
+ goal.instance_variable_set(:@deadline, hash[:deadline])
30
+ goal.instance_variable_set(:@task_id, hash[:task_id])
31
+ goal.instance_variable_set(:@runner_mapping, hash[:runner_mapping])
32
+ goal.instance_variable_set(:@created_at, hash[:created_at] ? Time.parse(hash[:created_at].to_s) : Time.now)
33
+ goal.instance_variable_set(:@updated_at, hash[:updated_at] ? Time.parse(hash[:updated_at].to_s) : Time.now)
34
+ goal
35
+ end
17
36
 
18
37
  def initialize(content:, parent_id: nil, domain: :general, priority: DEFAULT_PRIORITY, deadline: nil)
19
- @id = SecureRandom.uuid
20
- @content = content
21
- @parent_id = parent_id
22
- @sub_goal_ids = []
23
- @status = :proposed
24
- @priority = priority.clamp(0.0, 1.0)
25
- @progress = 0.0
26
- @domain = domain
27
- @deadline = deadline
28
- @created_at = Time.now
29
- @updated_at = Time.now
38
+ @id = SecureRandom.uuid
39
+ @content = content
40
+ @parent_id = parent_id
41
+ @sub_goal_ids = []
42
+ @status = :proposed
43
+ @priority = priority.clamp(0.0, 1.0)
44
+ @progress = 0.0
45
+ @domain = domain
46
+ @deadline = deadline
47
+ @created_at = Time.now
48
+ @updated_at = Time.now
49
+ @task_id = nil
50
+ @runner_mapping = nil
30
51
  end
31
52
 
32
53
  def activate!
@@ -88,6 +109,12 @@ module Legion
88
109
  @updated_at = Time.now
89
110
  end
90
111
 
112
+ def assign_task!(task_id:, runner_mapping:)
113
+ @task_id = task_id
114
+ @runner_mapping = runner_mapping
115
+ @updated_at = Time.now
116
+ end
117
+
91
118
  def add_sub_goal(goal_id)
92
119
  @sub_goal_ids << goal_id unless @sub_goal_ids.include?(goal_id)
93
120
  end
@@ -140,6 +167,8 @@ module Legion
140
167
  overdue: overdue?,
141
168
  root: root?,
142
169
  leaf: leaf?,
170
+ task_id: @task_id,
171
+ runner_mapping: @runner_mapping,
143
172
  created_at: @created_at,
144
173
  updated_at: @updated_at
145
174
  }
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/extensions/agentic/executive/goal_management/helpers/goal_persistence'
4
+
3
5
  module Legion
4
6
  module Extensions
5
7
  module Agentic
@@ -14,6 +16,9 @@ module Legion
14
16
  def initialize
15
17
  @goals = {}
16
18
  @root_goal_ids = []
19
+ @mutex = Mutex.new
20
+ @persistence = GoalPersistence.new
21
+ rehydrate_from_cache
17
22
  end
18
23
 
19
24
  def add_goal(content:, parent_id: nil, domain: :general, priority: DEFAULT_PRIORITY, deadline: nil)
@@ -36,7 +41,8 @@ module Legion
36
41
  @root_goal_ids << goal.id
37
42
  end
38
43
 
39
- Legion::Logging.debug "[goal_management] add_goal id=#{goal.id} domain=#{domain} priority=#{priority.round(2)}"
44
+ persist_goal(goal)
45
+ log.debug "[goal_management] add_goal id=#{goal.id} domain=#{domain} priority=#{priority.round(2)}"
40
46
  { success: true, goal: goal.to_h }
41
47
  end
42
48
 
@@ -55,7 +61,7 @@ module Legion
55
61
  end
56
62
 
57
63
  failures = created.reject { |r| r[:success] }
58
- Legion::Logging.debug "[goal_management] decompose parent=#{goal_id} created=#{created.size - failures.size} failed=#{failures.size}"
64
+ log.debug "[goal_management] decompose parent=#{goal_id} created=#{created.size - failures.size} failed=#{failures.size}"
59
65
  { success: true, parent_id: goal_id, created: created, failures: failures.size }
60
66
  end
61
67
 
@@ -64,7 +70,8 @@ module Legion
64
70
  return { success: false, error: "goal #{goal_id} not found" } unless goal
65
71
 
66
72
  activated = goal.activate!
67
- Legion::Logging.debug "[goal_management] activate goal=#{goal_id} result=#{activated}"
73
+ persist_goal(goal) if activated
74
+ log.debug "[goal_management] activate goal=#{goal_id} result=#{activated}"
68
75
  { success: activated, goal_id: goal_id, status: goal.status }
69
76
  end
70
77
 
@@ -73,7 +80,8 @@ module Legion
73
80
  return { success: false, error: "goal #{goal_id} not found" } unless goal
74
81
 
75
82
  completed = goal.complete!
76
- Legion::Logging.debug "[goal_management] complete goal=#{goal_id} result=#{completed}"
83
+ persist_goal(goal) if completed
84
+ log.debug "[goal_management] complete goal=#{goal_id} result=#{completed}"
77
85
  { success: completed, goal_id: goal_id, status: goal.status }
78
86
  end
79
87
 
@@ -82,7 +90,8 @@ module Legion
82
90
  return { success: false, error: "goal #{goal_id} not found" } unless goal
83
91
 
84
92
  abandoned = goal.abandon!
85
- Legion::Logging.debug "[goal_management] abandon goal=#{goal_id} result=#{abandoned}"
93
+ persist_goal(goal) if abandoned
94
+ log.debug "[goal_management] abandon goal=#{goal_id} result=#{abandoned}"
86
95
  { success: abandoned, goal_id: goal_id, status: goal.status }
87
96
  end
88
97
 
@@ -91,7 +100,8 @@ module Legion
91
100
  return { success: false, error: "goal #{goal_id} not found" } unless goal
92
101
 
93
102
  blocked = goal.block!
94
- Legion::Logging.debug "[goal_management] block goal=#{goal_id} result=#{blocked}"
103
+ persist_goal(goal) if blocked
104
+ log.debug "[goal_management] block goal=#{goal_id} result=#{blocked}"
95
105
  { success: blocked, goal_id: goal_id, status: goal.status }
96
106
  end
97
107
 
@@ -100,7 +110,8 @@ module Legion
100
110
  return { success: false, error: "goal #{goal_id} not found" } unless goal
101
111
 
102
112
  unblocked = goal.unblock!
103
- Legion::Logging.debug "[goal_management] unblock goal=#{goal_id} result=#{unblocked}"
113
+ persist_goal(goal) if unblocked
114
+ log.debug "[goal_management] unblock goal=#{goal_id} result=#{unblocked}"
104
115
  { success: unblocked, goal_id: goal_id, status: goal.status }
105
116
  end
106
117
 
@@ -110,7 +121,8 @@ module Legion
110
121
 
111
122
  goal.advance_progress!(amount)
112
123
  propagate_progress_to_parent(goal_id)
113
- Legion::Logging.debug "[goal_management] advance_progress goal=#{goal_id} progress=#{goal.progress.round(2)}"
124
+ persist_goal(goal)
125
+ log.debug "[goal_management] advance_progress goal=#{goal_id} progress=#{goal.progress.round(2)}"
114
126
  { success: true, goal_id: goal_id, progress: goal.progress }
115
127
  end
116
128
 
@@ -131,7 +143,7 @@ module Legion
131
143
  conflicts = raw.select { |c| c[:conflict_score] > 0.0 }
132
144
  .sort_by { |c| -c[:conflict_score] }
133
145
 
134
- Legion::Logging.debug "[goal_management] detect_conflicts goal=#{goal_id} conflicts=#{conflicts.size}"
146
+ log.debug "[goal_management] detect_conflicts goal=#{goal_id} conflicts=#{conflicts.size}"
135
147
  { success: true, goal_id: goal_id, conflicts: conflicts, count: conflicts.size }
136
148
  end
137
149
 
@@ -163,13 +175,67 @@ module Legion
163
175
  active.sort_by { |g| -g.priority }.first(limit)
164
176
  end
165
177
 
178
+ URGENCY_BOOST = { critical: 0.3, high: 0.2, moderate: 0.1, low: 0.05 }.freeze
179
+
180
+ def reprioritize!(signal:)
181
+ domain = signal[:domain]&.to_sym
182
+ boost = URGENCY_BOOST[signal[:urgency]&.to_sym] || 0.05
183
+ adjusted = 0
184
+
185
+ @goals.each_value do |goal|
186
+ next unless goal.domain == domain && goal.active?
187
+
188
+ times = (boost / Constants::PRIORITY_BOOST).ceil
189
+ times.times { goal.boost_priority! }
190
+ persist_goal(goal)
191
+ adjusted += 1
192
+ end
193
+
194
+ log.info "[goal_engine] reprioritize! domain=#{domain} boost=#{boost} adjusted=#{adjusted}"
195
+ { adjusted: adjusted, domain: domain, boost: boost }
196
+ end
197
+
198
+ def assign_task_to_goal(goal_id:, task_id:, runner_mapping:)
199
+ goal = @goals[goal_id]
200
+ return { success: false, error: "goal #{goal_id} not found" } unless goal
201
+
202
+ goal.assign_task!(task_id: task_id, runner_mapping: runner_mapping)
203
+ persist_goal(goal)
204
+ log.debug "[goal_management] assign_task goal=#{goal_id} task_id=#{task_id}"
205
+ { success: true, goal_id: goal_id, task_id: task_id }
206
+ end
207
+
166
208
  def decay_all_priorities!
167
209
  inactive = @goals.values.reject { |g| g.status == :active }
168
210
  inactive.each(&:decay_priority!)
169
- Legion::Logging.debug "[goal_management] decay_all inactive=#{inactive.size}"
211
+ log.debug "[goal_management] decay_all inactive=#{inactive.size}"
170
212
  { decayed: inactive.size }
171
213
  end
172
214
 
215
+ def update_from_task_event(task_id:, status:, result: nil)
216
+ @mutex.synchronize do
217
+ goal = @goals.values.find { |g| g.task_id == task_id }
218
+ return { found: false } unless goal
219
+
220
+ case status
221
+ when 'task.completed'
222
+ goal.advance_progress!(1.0 - goal.progress)
223
+ goal.complete! if goal.progress >= Constants::PROGRESS_THRESHOLD
224
+ propagate_progress_to_parent(goal.id) if goal.parent_id
225
+ persist_goal(goal)
226
+ log.info "[goal_engine] goal status change goal=#{goal.id} transition=completed"
227
+ { found: true, goal_id: goal.id, new_status: goal.status, progress: goal.progress }
228
+ when 'task.exception', 'task.failed'
229
+ goal.block!
230
+ persist_goal(goal)
231
+ log.info "[goal_engine] goal status change goal=#{goal.id} transition=blocked"
232
+ { found: true, goal_id: goal.id, new_status: :blocked, error: result }
233
+ else
234
+ { found: true, goal_id: goal.id, unhandled_status: status }
235
+ end
236
+ end
237
+ end
238
+
173
239
  def goal_report
174
240
  statuses = GOAL_STATUSES.to_h { |s| [s, 0] }
175
241
  @goals.each_value { |g| statuses[g.status] += 1 }
@@ -192,6 +258,28 @@ module Legion
192
258
 
193
259
  private
194
260
 
261
+ def log
262
+ Legion::Logging
263
+ end
264
+
265
+ def persist_goal(goal)
266
+ @persistence.save_goal(goal.to_h)
267
+ end
268
+
269
+ def rehydrate_from_cache
270
+ cached = @persistence.load_all
271
+ return if cached.empty?
272
+
273
+ cached.each do |id, goal_hash|
274
+ goal = Goal.from_h(goal_hash)
275
+ @goals[id] = goal
276
+ @root_goal_ids << id if goal.root?
277
+ end
278
+ log.info "[goal_engine] rehydrated #{cached.size} goals from cache"
279
+ rescue StandardError => e
280
+ log.error "GoalEngine: rehydration failed: #{e.message}"
281
+ end
282
+
195
283
  def depth_of(goal_id, current_depth = 0)
196
284
  goal = @goals[goal_id]
197
285
  return current_depth unless goal&.parent_id
@@ -212,6 +300,7 @@ module Legion
212
300
  avg = children.sum(&:progress).round(10) / children.size
213
301
  parent.instance_variable_set(:@progress, avg.round(10))
214
302
  parent.instance_variable_set(:@updated_at, Time.now)
303
+ persist_goal(parent)
215
304
  propagate_progress_to_parent(goal.parent_id)
216
305
  end
217
306