trak_flow 0.1.3 → 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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.fasterer.yml +10 -0
  3. data/.loki +33 -0
  4. data/.reek.yml +161 -0
  5. data/.rubocop.yml +187 -0
  6. data/Archspec.rb +45 -0
  7. data/CHANGELOG.md +52 -1
  8. data/Gemfile +12 -0
  9. data/Gemfile.lock +124 -93
  10. data/README.md +15 -2
  11. data/_typos.toml +17 -0
  12. data/docs/api/ruby-library.md +2 -2
  13. data/docs/cli/admin-commands.md +3 -3
  14. data/docs/core-concepts/overview.md +2 -2
  15. data/docs/getting-started/configuration.md +2 -2
  16. data/docs/getting-started/installation.md +22 -5
  17. data/docs/index.md +2 -2
  18. data/docs/mcp/integration.md +11 -8
  19. data/docs/mcp/overview.md +29 -8
  20. data/examples/cli_demo.sh +1 -1
  21. data/examples/mcp/Gemfile.lock +147 -61
  22. data/lib/trak_flow/cli/admin_commands.rb +77 -59
  23. data/lib/trak_flow/cli/config_commands.rb +9 -14
  24. data/lib/trak_flow/cli/dep_commands.rb +3 -3
  25. data/lib/trak_flow/cli/label_commands.rb +3 -3
  26. data/lib/trak_flow/cli/main_commands.rb +127 -94
  27. data/lib/trak_flow/cli/plan_commands.rb +32 -25
  28. data/lib/trak_flow/cli/workflow_commands.rb +5 -4
  29. data/lib/trak_flow/cli.rb +2 -2
  30. data/lib/trak_flow/config/defaults.yml +5 -1
  31. data/lib/trak_flow/config/section.rb +3 -5
  32. data/lib/trak_flow/config.rb +20 -34
  33. data/lib/trak_flow/graph/dependency_graph.rb +72 -69
  34. data/lib/trak_flow/mcp/resources/dependency_graph.rb +13 -9
  35. data/lib/trak_flow/mcp/resources/task_by_id.rb +4 -3
  36. data/lib/trak_flow/mcp/server.rb +77 -76
  37. data/lib/trak_flow/mcp/tools/task_create.rb +17 -17
  38. data/lib/trak_flow/models/task.rb +21 -17
  39. data/lib/trak_flow/storage/database.rb +26 -16
  40. data/lib/trak_flow/storage/jsonl.rb +60 -62
  41. data/lib/trak_flow/version.rb +1 -1
  42. data/lib/trak_flow.rb +0 -1
  43. data/trak_flow.gemspec +57 -0
  44. metadata +11 -31
@@ -13,14 +13,16 @@ module TrakFlow
13
13
  option :stealth, type: :boolean, default: false, desc: "Local-only mode without git integration"
14
14
 
15
15
  def init
16
+ trak_dir = TrakFlow.trak_flow_dir
17
+
16
18
  if TrakFlow.initialized?
17
19
  output({ success: false, error: "TrakFlow already initialized" }) do
18
- puts pastel.red("Error: TrakFlow already initialized in #{TrakFlow.trak_flow_dir}")
20
+ puts pastel.red("Error: TrakFlow already initialized in #{trak_dir}")
19
21
  end
20
22
  return
21
23
  end
22
24
 
23
- FileUtils.mkdir_p(TrakFlow.trak_flow_dir)
25
+ FileUtils.mkdir_p(trak_dir)
24
26
 
25
27
  db = Storage::Database.new
26
28
  db.connect
@@ -32,8 +34,8 @@ module TrakFlow
32
34
 
33
35
  setup_gitignore unless options[:stealth]
34
36
 
35
- output({ success: true, path: TrakFlow.trak_flow_dir }) do
36
- puts pastel.green("Initialized TrakFlow in #{TrakFlow.trak_flow_dir}")
37
+ output({ success: true, path: trak_dir }) do
38
+ puts pastel.green("Initialized TrakFlow in #{trak_dir}")
37
39
  end
38
40
  end
39
41
 
@@ -47,7 +49,7 @@ module TrakFlow
47
49
  database_path: TrakFlow.database_path,
48
50
  jsonl_path: TrakFlow.jsonl_path,
49
51
  config_path: TrakFlow.config_path,
50
- initialized: TrakFlow.initialized?,
52
+ initialized: TrakFlow.initialized?
51
53
  }
52
54
 
53
55
  output(info_data) do
@@ -71,46 +73,12 @@ module TrakFlow
71
73
  def create(title)
72
74
  validate_option!(:type, TrakFlow::TYPES, options[:type])
73
75
  validate_option!(:priority, TrakFlow::PRIORITIES, options[:priority])
76
+ raise ValidationError, "Plans cannot be ephemeral" if options[:plan] && options[:ephemeral]
74
77
 
75
78
  with_database do |db|
76
- description = options[:description]
77
-
78
- if options[:body_file]
79
- description = options[:body_file] == "-" ? $stdin.read : File.read(options[:body_file])
80
- end
81
-
82
- raise ValidationError, "Plans cannot be ephemeral" if options[:plan] && options[:ephemeral]
83
-
84
- task = if options[:parent]
85
- db.create_child_task(options[:parent], {
86
- title: title,
87
- description: description,
88
- type: options[:type],
89
- priority: options[:priority],
90
- assignee: options[:assignee],
91
- ephemeral: options[:ephemeral],
92
- })
93
- else
94
- new_task = Models::Task.new(
95
- title: title,
96
- description: description,
97
- type: options[:type],
98
- priority: options[:priority],
99
- assignee: options[:assignee],
100
- plan: options[:plan],
101
- ephemeral: options[:ephemeral],
102
- )
103
- db.create_task(new_task)
104
- end
105
-
106
- options[:labels]&.each do |label_name|
107
- db.add_label(Models::Label.new(task_id: task.id, name: label_name))
108
- end
109
-
110
- options[:deps]&.each do |dep_spec|
111
- type, target_id = dep_spec.split(":", 2)
112
- db.add_dependency(Models::Dependency.new(source_id: task.id, target_id: target_id, type: type))
113
- end
79
+ task = build_task(db, title, resolve_description)
80
+ attach_labels(db, task)
81
+ attach_dependencies(db, task)
114
82
 
115
83
  output(task.to_h) do
116
84
  puts "Created: #{pastel.bold(task.id)} - #{task.title}"
@@ -147,31 +115,7 @@ module TrakFlow
147
115
 
148
116
  def list
149
117
  with_database do |db|
150
- filters = {
151
- status: options[:status],
152
- priority: options[:priority],
153
- type: options[:type],
154
- assignee: options[:assignee],
155
- title_contains: options[:title_contains],
156
- }.compact
157
-
158
- tasks = db.list_tasks(filters)
159
-
160
- if options[:label]
161
- tasks = tasks.select do |task|
162
- task_labels = db.find_labels(task.id).map(&:name)
163
- options[:label].all? { |l| task_labels.include?(l) }
164
- end
165
- end
166
-
167
- if options[:label_any]
168
- tasks = tasks.select do |task|
169
- task_labels = db.find_labels(task.id).map(&:name)
170
- options[:label_any].any? { |l| task_labels.include?(l) }
171
- end
172
- end
173
-
174
- tasks = tasks.take(options[:limit]) if options[:limit]
118
+ tasks = filtered_tasks(db)
175
119
 
176
120
  output(tasks.map(&:to_h)) do
177
121
  if tasks.empty?
@@ -197,13 +141,7 @@ module TrakFlow
197
141
 
198
142
  with_database do |db|
199
143
  task = db.find_task!(id)
200
-
201
- task.status = options[:status] if options[:status]
202
- task.priority = options[:priority] if options[:priority]
203
- task.title = options[:title] if options[:title]
204
- task.description = options[:description] if options[:description]
205
- task.assignee = options[:assignee] if options[:assignee]
206
-
144
+ apply_task_updates(task)
207
145
  db.update_task(task)
208
146
 
209
147
  output(task.to_h) do
@@ -288,17 +226,18 @@ module TrakFlow
288
226
  def sync
289
227
  with_database do |db|
290
228
  jsonl = Storage::Jsonl.new
229
+ jsonl_path = jsonl.path
291
230
 
292
231
  if jsonl.exists?
293
232
  jsonl.import(db)
294
- output({ success: true, action: "imported", path: jsonl.path }) do
295
- puts pastel.green("Imported from #{jsonl.path}")
233
+ output({ success: true, action: "imported", path: jsonl_path }) do
234
+ puts pastel.green("Imported from #{jsonl_path}")
296
235
  end
297
236
  end
298
237
 
299
238
  jsonl.export(db)
300
- output({ success: true, action: "exported", path: jsonl.path }) do
301
- puts pastel.green("Exported to #{jsonl.path}")
239
+ output({ success: true, action: "exported", path: jsonl_path }) do
240
+ puts pastel.green("Exported to #{jsonl_path}")
302
241
  end
303
242
 
304
243
  unless TrakFlow.config.get("stealth") || TrakFlow.config.get("no_push")
@@ -328,33 +267,127 @@ module TrakFlow
328
267
 
329
268
  private
330
269
 
270
+ # Reads the task description from --description, --body-file, or stdin ("-").
271
+ def resolve_description
272
+ body_file = options[:body_file]
273
+ return $stdin.read if body_file == "-"
274
+ return File.read(body_file) if body_file
275
+
276
+ options[:description]
277
+ end
278
+
279
+ # Creates the task — as a child of --parent when given, standalone otherwise.
280
+ def build_task(db, title, description)
281
+ attrs = {
282
+ title: title,
283
+ description: description,
284
+ type: options[:type],
285
+ priority: options[:priority],
286
+ assignee: options[:assignee],
287
+ ephemeral: options[:ephemeral]
288
+ }
289
+
290
+ if options[:parent]
291
+ db.create_child_task(options[:parent], attrs)
292
+ else
293
+ db.create_task(Models::Task.new(plan: options[:plan], **attrs))
294
+ end
295
+ end
296
+
297
+ def attach_labels(db, task)
298
+ options[:labels]&.each do |label_name|
299
+ db.add_label(Models::Label.new(task_id: task.id, name: label_name))
300
+ end
301
+ end
302
+
303
+ def attach_dependencies(db, task)
304
+ options[:deps]&.each do |dep_spec|
305
+ type, target_id = dep_spec.split(":", 2)
306
+ db.add_dependency(Models::Dependency.new(source_id: task.id, target_id: target_id, type: type))
307
+ end
308
+ end
309
+
310
+ # Applies the list command's label/limit options on top of the db filters.
311
+ def filtered_tasks(db)
312
+ tasks = db.list_tasks(list_filters)
313
+ tasks = tasks.select { |task| (options[:label] - task_label_names(db, task)).empty? } if options[:label]
314
+ tasks = tasks.select { |task| options[:label_any].intersect?(task_label_names(db, task)) } if options[:label_any]
315
+ options[:limit] ? tasks.take(options[:limit]) : tasks
316
+ end
317
+
318
+ def list_filters
319
+ {
320
+ status: options[:status],
321
+ priority: options[:priority],
322
+ type: options[:type],
323
+ assignee: options[:assignee],
324
+ title_contains: options[:title_contains]
325
+ }.compact
326
+ end
327
+
328
+ def task_label_names(db, task)
329
+ db.find_labels(task.id).map(&:name)
330
+ end
331
+
332
+ # Copies each given update option onto the task; untouched fields keep their values.
333
+ def apply_task_updates(task)
334
+ %i[status priority title description assignee].each do |field|
335
+ task.send("#{field}=", options[field]) if options[field]
336
+ end
337
+ end
338
+
331
339
  def print_task_details(task, labels, deps, comments)
340
+ print_task_summary(task)
341
+ print_task_body(task, labels)
342
+ print_task_dependencies(task, deps)
343
+ print_task_comments(comments)
344
+ end
345
+
346
+ def print_task_summary(task)
332
347
  puts pastel.bold("Task: #{task.id}")
333
- puts "Title: #{task.title}"
334
- puts "Status: #{colorize_status(task.status)}"
335
- puts "Priority: #{colorize_priority(task.priority)}"
336
- puts "Type: #{task.type}"
337
- puts "Assignee: #{task.assignee || "unassigned"}"
348
+ puts <<~SUMMARY
349
+ Title: #{task.title}
350
+ Status: #{colorize_status(task.status)}
351
+ Priority: #{colorize_priority(task.priority)}
352
+ Type: #{task.type}
353
+ Assignee: #{task.assignee || "unassigned"}
354
+ SUMMARY
338
355
  puts "Parent: #{task.parent_id}" if task.parent_id
339
356
  puts "Created: #{task.created_at}"
340
357
  puts "Updated: #{task.updated_at}"
341
358
  puts "Closed: #{task.closed_at}" if task.closed_at
342
- puts ""
343
- puts "Description:"
344
- puts task.description.empty? ? "(none)" : task.description
345
- puts ""
346
- puts "Labels: #{labels.empty? ? "(none)" : labels.map(&:name).join(", ")}"
359
+ end
360
+
361
+ def print_task_body(task, labels)
362
+ puts <<~BODY
363
+
364
+ Description:
365
+ #{task.description.empty? ? "(none)" : task.description}
366
+
367
+ Labels: #{labels.empty? ? "(none)" : labels.map(&:name).join(", ")}
368
+ BODY
369
+ end
370
+
371
+ def print_task_dependencies(task, deps)
347
372
  puts ""
348
373
  puts "Dependencies:"
349
374
  if deps.empty?
350
375
  puts " (none)"
351
376
  else
352
- deps.each do |dep|
353
- direction = dep.source_id == task.id ? "->" : "<-"
354
- other_id = dep.source_id == task.id ? dep.target_id : dep.source_id
355
- puts " #{direction} #{other_id} (#{dep.type})"
356
- end
377
+ deps.each { |dep| puts " #{format_dependency(task, dep)}" }
357
378
  end
379
+ end
380
+
381
+ # Renders one dependency relative to the task being shown:
382
+ # "-> other (type)" when the task is the source, "<- other (type)" otherwise.
383
+ def format_dependency(task, dep)
384
+ outgoing = dep.source_id == task.id
385
+ direction = outgoing ? "->" : "<-"
386
+ other_id = outgoing ? dep.target_id : dep.source_id
387
+ "#{direction} #{other_id} (#{dep.type})"
388
+ end
389
+
390
+ def print_task_comments(comments)
358
391
  puts ""
359
392
  puts "Comments: #{comments.size}"
360
393
  comments.each do |comment|
@@ -132,28 +132,8 @@ module TrakFlow
132
132
  raise Error, "#{plan_id} is not a Plan" unless plan.plan?
133
133
 
134
134
  vars = options[:var] || {}
135
-
136
- workflow = Models::Task.new(
137
- title: interpolate_vars(plan.title, vars),
138
- description: interpolate_vars(plan.description, vars),
139
- type: plan.type,
140
- priority: plan.priority,
141
- source_plan_id: plan.id,
142
- ephemeral: ephemeral
143
- )
144
- db.create_task(workflow)
145
- workflow.append_trace("INSTANTIATED", "from Plan #{plan.id}")
146
- db.update_task(workflow)
147
-
148
- db.find_plan_tasks(plan_id).each do |step|
149
- db.create_child_task(workflow.id, {
150
- title: interpolate_vars(step.title, vars),
151
- description: interpolate_vars(step.description, vars),
152
- type: step.type,
153
- priority: step.priority,
154
- ephemeral: ephemeral
155
- })
156
- end
135
+ workflow = create_workflow_from_plan(db, plan, vars, ephemeral)
136
+ instantiate_plan_steps(db, plan_id, workflow.id, vars, ephemeral)
157
137
 
158
138
  mode = ephemeral ? "ephemeral" : "persistent"
159
139
  output(workflow.to_h) do
@@ -162,6 +142,33 @@ module TrakFlow
162
142
  end
163
143
  end
164
144
 
145
+ def create_workflow_from_plan(db, plan, vars, ephemeral)
146
+ workflow = Models::Task.new(
147
+ title: interpolate_vars(plan.title, vars),
148
+ description: interpolate_vars(plan.description, vars),
149
+ type: plan.type,
150
+ priority: plan.priority,
151
+ source_plan_id: plan.id,
152
+ ephemeral: ephemeral
153
+ )
154
+ db.create_task(workflow)
155
+ workflow.append_trace("INSTANTIATED", "from Plan #{plan.id}")
156
+ db.update_task(workflow)
157
+ workflow
158
+ end
159
+
160
+ def instantiate_plan_steps(db, plan_id, workflow_id, vars, ephemeral)
161
+ db.find_plan_tasks(plan_id).each do |step|
162
+ db.create_child_task(workflow_id, {
163
+ title: interpolate_vars(step.title, vars),
164
+ description: interpolate_vars(step.description, vars),
165
+ type: step.type,
166
+ priority: step.priority,
167
+ ephemeral: ephemeral
168
+ })
169
+ end
170
+ end
171
+
165
172
  def interpolate_vars(text, vars)
166
173
  return text if text.nil? || vars.empty?
167
174
 
@@ -171,13 +178,13 @@ module TrakFlow
171
178
  end
172
179
 
173
180
  # Delegate helper methods to parent CLI
174
- def with_database(&block) = CLI.new.with_database(&block)
181
+ def with_database(&) = CLI.new.with_database(&)
175
182
 
176
- def output(json_data, &human_block)
183
+ def output(json_data)
177
184
  if options[:json]
178
185
  puts Oj.dump(json_data, mode: :compat, indent: 2)
179
186
  else
180
- human_block.call
187
+ yield
181
188
  end
182
189
  end
183
190
  end
@@ -72,7 +72,8 @@ module TrakFlow
72
72
  with_database do |db|
73
73
  workflow = db.find_task!(id)
74
74
 
75
- summary_text = File.exist?(options[:summary]) ? File.read(options[:summary]) : options[:summary]
75
+ summary = options[:summary]
76
+ summary_text = File.exist?(summary) ? File.read(summary) : summary
76
77
 
77
78
  workflow.notes = "#{workflow.notes}\n\n[Summary]\n#{summary_text}".strip
78
79
  workflow.close!(reason: "summarized")
@@ -118,14 +119,14 @@ module TrakFlow
118
119
  end
119
120
 
120
121
  # Delegate helper methods to parent CLI
121
- def with_database(&block) = CLI.new.with_database(&block)
122
+ def with_database(&) = CLI.new.with_database(&)
122
123
  def status_icon(status) = CLI.new.status_icon(status)
123
124
 
124
- def output(json_data, &human_block)
125
+ def output(json_data)
125
126
  if options[:json]
126
127
  puts Oj.dump(json_data, mode: :compat, indent: 2)
127
128
  else
128
- human_block.call
129
+ yield
129
130
  end
130
131
  end
131
132
  end
data/lib/trak_flow/cli.rb CHANGED
@@ -18,11 +18,11 @@ module TrakFlow
18
18
  puts Oj.dump(data, mode: :compat, indent: 2)
19
19
  end
20
20
 
21
- def output(json_data, &human_block)
21
+ def output(json_data)
22
22
  if json?
23
23
  output_json(json_data)
24
24
  else
25
- human_block.call
25
+ yield
26
26
  end
27
27
  end
28
28
 
@@ -102,10 +102,14 @@ defaults:
102
102
 
103
103
  # ---------------------------------------------------------------------------
104
104
  # MCP Server Configuration
105
- # Access: TrakFlow.config.mcp.port
105
+ # Access: TrakFlow.config.mcp.port, TrakFlow.config.mcp.handler
106
106
  # ---------------------------------------------------------------------------
107
107
  mcp:
108
108
  port: 3333
109
+ # Rack server for the HTTP transport, by Rackup handler name (e.g.
110
+ # "puma", "falcon", "webrick"). Null lets Rackup pick whatever server
111
+ # the bundle already carries. The server must support rack.hijack.
112
+ handler: ~
109
113
 
110
114
  # ---------------------------------------------------------------------------
111
115
  # Actor Configuration
@@ -16,14 +16,12 @@ module TrakFlow
16
16
  end
17
17
  end
18
18
 
19
- def method_missing(method, *args, &block)
19
+ def method_missing(method, *args, &)
20
20
  key = method.to_s
21
21
  if key.end_with?('=')
22
22
  @data[key.chomp('=').to_sym] = args.first
23
23
  elsif @data.key?(method)
24
24
  @data[method]
25
- else
26
- nil
27
25
  end
28
26
  end
29
27
 
@@ -55,8 +53,8 @@ module TrakFlow
55
53
  @data.keys
56
54
  end
57
55
 
58
- def each(&block)
59
- @data.each(&block)
56
+ def each(&)
57
+ @data.each(&)
60
58
  end
61
59
 
62
60
  private
@@ -53,11 +53,13 @@ module TrakFlow
53
53
  SCHEMA = raw_yaml[:defaults] || {}
54
54
  rescue StandardError => e
55
55
  raise TrakFlow::ConfigurationError,
56
- "Could not load schema from #{DEFAULTS_PATH}: #{e.message}"
56
+ "Could not load schema from #{DEFAULTS_PATH}: #{e.message}"
57
57
  end
58
58
 
59
59
  # Nested section attributes (defined as hashes, converted to ConfigSection)
60
- attr_config :output, :daemon, :sync, :create, :validation, :id, :import, :export, :storage, :database, :mcp
60
+ SECTION_KEYS = %i[output daemon sync create validation id import export storage database mcp].freeze
61
+
62
+ attr_config(*SECTION_KEYS)
61
63
 
62
64
  # Top-level scalar attributes
63
65
  attr_config :actor
@@ -68,7 +70,7 @@ module TrakFlow
68
70
 
69
71
  def self.config_section_with_defaults(section_key)
70
72
  defaults = SCHEMA[section_key] || {}
71
- ->(v) {
73
+ lambda { |v|
72
74
  return v if v.is_a?(ConfigSection)
73
75
  incoming = v || {}
74
76
  merged = deep_merge_hashes(defaults.dup, incoming)
@@ -86,19 +88,7 @@ module TrakFlow
86
88
  end
87
89
  end
88
90
 
89
- coerce_types(
90
- output: config_section_with_defaults(:output),
91
- daemon: config_section_with_defaults(:daemon),
92
- sync: config_section_with_defaults(:sync),
93
- create: config_section_with_defaults(:create),
94
- validation: config_section_with_defaults(:validation),
95
- id: config_section_with_defaults(:id),
96
- import: config_section_with_defaults(:import),
97
- export: config_section_with_defaults(:export),
98
- storage: config_section_with_defaults(:storage),
99
- database: config_section_with_defaults(:database),
100
- mcp: config_section_with_defaults(:mcp)
101
- )
91
+ coerce_types(SECTION_KEYS.to_h { |key| [key, config_section_with_defaults(key)] })
102
92
 
103
93
  on_load :setup_defaults
104
94
 
@@ -229,13 +219,12 @@ module TrakFlow
229
219
  mapping = LEGACY_KEY_MAP[key.to_s]
230
220
  return unless mapping
231
221
 
232
- if mapping.is_a?(Array)
233
- if mapping.length == 1
234
- send("#{mapping[0]}=", value)
235
- else
236
- section = send(mapping[0])
237
- section.send("#{mapping[1]}=", value)
238
- end
222
+ return unless mapping.is_a?(Array)
223
+ if mapping.length == 1
224
+ send("#{mapping[0]}=", value)
225
+ else
226
+ section = send(mapping[0])
227
+ section.send("#{mapping[1]}=", value)
239
228
  end
240
229
  end
241
230
 
@@ -244,19 +233,16 @@ module TrakFlow
244
233
  def setup_defaults
245
234
  # Ensure all sections are initialized with defaults even when no config files exist
246
235
  # Manually apply coercion since it only fires when values come from config sources
247
- self.output = self.class.config_section_with_defaults(:output).call(output) unless output.is_a?(ConfigSection)
248
- self.daemon = self.class.config_section_with_defaults(:daemon).call(daemon) unless daemon.is_a?(ConfigSection)
249
- self.sync = self.class.config_section_with_defaults(:sync).call(sync) unless sync.is_a?(ConfigSection)
250
- self.create = self.class.config_section_with_defaults(:create).call(create) unless create.is_a?(ConfigSection)
251
- self.validation = self.class.config_section_with_defaults(:validation).call(validation) unless validation.is_a?(ConfigSection)
252
- self.id = self.class.config_section_with_defaults(:id).call(self.id) unless self.id.is_a?(ConfigSection)
253
- self.import = self.class.config_section_with_defaults(:import).call(self.import) unless self.import.is_a?(ConfigSection)
254
- self.export = self.class.config_section_with_defaults(:export).call(self.export) unless self.export.is_a?(ConfigSection)
255
- self.storage = self.class.config_section_with_defaults(:storage).call(self.storage) unless self.storage.is_a?(ConfigSection)
256
- self.database = self.class.config_section_with_defaults(:database).call(self.database) unless self.database.is_a?(ConfigSection)
257
- self.mcp = self.class.config_section_with_defaults(:mcp).call(self.mcp) unless self.mcp.is_a?(ConfigSection)
236
+ SECTION_KEYS.each { |key| ensure_section!(key) }
258
237
  self.actor ||= ENV.fetch('USER', 'unknown')
259
238
  end
239
+
240
+ def ensure_section!(key)
241
+ current = send(key)
242
+ return if current.is_a?(ConfigSection)
243
+
244
+ send("#{key}=", self.class.config_section_with_defaults(key).call(current))
245
+ end
260
246
  end
261
247
 
262
248
  class << self