ace-support-config 0.10.2 → 0.16.3

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.
@@ -0,0 +1,1069 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "pathname"
6
+ require "rubygems"
7
+ require "thread"
8
+ require "time"
9
+ require "yaml"
10
+ require_relative "../molecules/setup_doctor_reporter"
11
+ require_relative "../models/config_templates"
12
+
13
+ module Ace
14
+ module Support
15
+ module Config
16
+ module Organisms
17
+ class SetupDoctor
18
+ PROVIDER_GEM = "ace-llm-providers-cli"
19
+ PASS = "pass"
20
+ WARN = "warn"
21
+ BLOCKER = "blocker"
22
+ SKIP = "skip"
23
+ INFO = "info"
24
+ STATUS_GLYPHS = {PASS => "✓", WARN => "✗", BLOCKER => "✗", SKIP => "○", INFO => "○", "running" => "○"}.freeze
25
+ STATUS_COLORS = {PASS => "\e[32m", WARN => "\e[31m", BLOCKER => "\e[31m", SKIP => "\e[33m", INFO => "\e[36m", "running" => "\e[33m"}.freeze
26
+ ANSI_RESET = "\e[0m"
27
+
28
+ CORE_ROLES = %w[commit doctor].freeze
29
+ UTILITY_ROLE_GROUPS = %w[_utility _utility-lite].freeze
30
+ ROLE_REFERENCE_PATTERN = /\brole:([A-Za-z0-9_-]+)\b/
31
+
32
+ def run(json: false, no_probe: false, probe: false, hygiene: false, verbose: false, colors: true, quiet: false, io: $stdout)
33
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ checks = []
35
+ stream = !json && !quiet
36
+
37
+ append_check(checks, check_artifact_hygiene, stream: stream, io: io)
38
+
39
+ package_check = check_provider_package
40
+ append_check(checks, package_check, stream: stream, io: io)
41
+
42
+ discovery_check = check_provider_discovery
43
+ append_check(checks, discovery_check, stream: stream, io: io)
44
+
45
+ append_check(checks, check_config_defaults, stream: stream, io: io)
46
+
47
+ provider_context = load_provider_context if package_check[:status] != BLOCKER
48
+
49
+ checks << check_alias_hygiene(provider_context)
50
+
51
+ role_health_check = check_role_health(provider_context)
52
+ append_check(checks, role_health_check, stream: stream, io: io)
53
+ checks << check_role_hygiene(provider_context)
54
+ append_check(checks, check_skill_sync, stream: stream, io: io)
55
+ utility_provider_targets = utility_provider_targets(provider_context)
56
+
57
+ append_check(checks, check_probe_readiness(
58
+ provider_context,
59
+ no_probe: no_probe,
60
+ probe: probe && !no_probe,
61
+ role_targets: utility_provider_targets,
62
+ structural_blockers: health_blocking?(checks),
63
+ progress_io: (stream ? io : nil)
64
+ ), stream: stream, io: io)
65
+
66
+ result = build_summary(checks).merge(
67
+ valid: !health_blocking?(checks),
68
+ duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at,
69
+ stats: build_stats(checks)
70
+ )
71
+
72
+ unless quiet
73
+ io.puts Molecules::SetupDoctorReporter.format_results(
74
+ result,
75
+ format: (json ? :json : :terminal),
76
+ hygiene: hygiene,
77
+ verbose: verbose,
78
+ colors: colors && !json
79
+ )
80
+ flush_io(io)
81
+ end
82
+ health_blocking?(checks) ? 1 : 0
83
+ end
84
+
85
+ private
86
+
87
+ def check_artifact_hygiene
88
+ root = project_root
89
+ gitignore_path = File.join(root, ".gitignore")
90
+ unless File.exist?(gitignore_path)
91
+ return check(
92
+ id: "artifact-hygiene",
93
+ kind: "health",
94
+ status: BLOCKER,
95
+ message: ".gitignore is missing at project root",
96
+ next_action: "Create #{gitignore_path} and add .ace-local/."
97
+ )
98
+ end
99
+
100
+ content = File.read(gitignore_path)
101
+ if gitignore_entry_present?(content, ".ace-local/")
102
+ check(id: "artifact-hygiene", kind: "health", status: PASS, message: ".ace-local/ is ignored")
103
+ else
104
+ check(
105
+ id: "artifact-hygiene",
106
+ kind: "health",
107
+ status: BLOCKER,
108
+ message: ".ace-local/ is not ignored",
109
+ next_action: "Add .ace-local/ to #{gitignore_path}."
110
+ )
111
+ end
112
+ end
113
+
114
+ def check_provider_package
115
+ installed = Gem::Specification.find_all_by_name(PROVIDER_GEM).any?
116
+ return check(id: "provider-package", kind: "health", status: PASS, message: "CLI provider package #{PROVIDER_GEM} is available") if installed
117
+
118
+ check(
119
+ id: "provider-package",
120
+ kind: "health",
121
+ status: BLOCKER,
122
+ message: "CLI provider package missing: #{PROVIDER_GEM}",
123
+ next_action: "Install #{PROVIDER_GEM} and run bundle install."
124
+ )
125
+ rescue => e
126
+ check(
127
+ id: "provider-package",
128
+ kind: "health",
129
+ status: WARN,
130
+ message: "Unable to verify provider package availability: #{e.message}",
131
+ next_action: "Verify #{PROVIDER_GEM} is installed."
132
+ )
133
+ end
134
+
135
+ def check_provider_discovery
136
+ _out, err, status = Open3.capture3("ace-llm", "--list-providers")
137
+ return check(id: "provider-discovery", kind: "health", status: PASS, message: "Provider discovery completed") if status.success?
138
+
139
+ check(
140
+ id: "provider-discovery",
141
+ kind: "health",
142
+ status: BLOCKER,
143
+ message: "Provider discovery failed",
144
+ next_action: discovery_next_action(err)
145
+ )
146
+ rescue Errno::ENOENT
147
+ check(
148
+ id: "provider-discovery",
149
+ kind: "health",
150
+ status: BLOCKER,
151
+ message: "ace-llm command is unavailable",
152
+ next_action: "Install ace-llm and #{PROVIDER_GEM}, then rerun ace-config doctor."
153
+ )
154
+ end
155
+
156
+ def check_config_defaults
157
+ summary = collect_config_defaults_summary
158
+ check(
159
+ id: "config-defaults",
160
+ kind: "info",
161
+ status: INFO,
162
+ message: "Config defaults comparison completed (#{summary[:customized]} customized, #{summary[:default]} default)",
163
+ details: summary[:details],
164
+ summary: summary
165
+ )
166
+ rescue => e
167
+ check(
168
+ id: "config-defaults",
169
+ kind: "info",
170
+ status: INFO,
171
+ message: "Config defaults comparison skipped: #{e.message}",
172
+ next_action: "Run ace-config diff --one-line to inspect config drift."
173
+ )
174
+ end
175
+
176
+ def check_skill_sync
177
+ out, err, status = Open3.capture3("ace-handbook", "status", "--format", "json")
178
+ unless status.success?
179
+ return check(
180
+ id: "skill-sync",
181
+ kind: "health",
182
+ status: WARN,
183
+ message: "Provider skill sync check failed",
184
+ next_action: "Run ace-handbook status to inspect provider skill projections.",
185
+ details: [err.to_s.strip, out.to_s.strip].reject(&:empty?)
186
+ )
187
+ end
188
+
189
+ snapshot = JSON.parse(out)
190
+ providers = Array(snapshot["providers"])
191
+ drifted = providers.select do |entry|
192
+ entry.fetch("missing", 0).to_i.positive? ||
193
+ entry.fetch("outdated", 0).to_i.positive? ||
194
+ entry.fetch("extra", 0).to_i.positive?
195
+ end
196
+
197
+ if drifted.empty?
198
+ total_skills = snapshot.dig("canonical", "total").to_i
199
+ return check(
200
+ id: "skill-sync",
201
+ kind: "health",
202
+ status: PASS,
203
+ message: "Provider skills are in sync (#{providers.length} providers, #{total_skills} skills)",
204
+ skill_sync: {providers: providers, canonical_total: total_skills}
205
+ )
206
+ end
207
+
208
+ check(
209
+ id: "skill-sync",
210
+ kind: "health",
211
+ status: WARN,
212
+ message: "Provider skill sync drift detected (#{drifted.length}/#{providers.length} providers)",
213
+ next_action: "Run ace-handbook sync to refresh provider-native skills.",
214
+ details: drifted.map { |entry| skill_sync_detail(entry) },
215
+ skill_sync: {providers: providers, drifted: drifted}
216
+ )
217
+ rescue Errno::ENOENT
218
+ check(
219
+ id: "skill-sync",
220
+ kind: "health",
221
+ status: WARN,
222
+ message: "Provider skill sync check unavailable: ace-handbook command is missing",
223
+ next_action: "Install ace-handbook, then rerun ace-config doctor."
224
+ )
225
+ rescue JSON::ParserError => e
226
+ check(
227
+ id: "skill-sync",
228
+ kind: "health",
229
+ status: WARN,
230
+ message: "Provider skill sync check returned invalid JSON: #{e.message}",
231
+ next_action: "Run ace-handbook status --format json to inspect provider skill projections."
232
+ )
233
+ rescue => e
234
+ check(
235
+ id: "skill-sync",
236
+ kind: "health",
237
+ status: WARN,
238
+ message: "Provider skill sync check failed: #{e.message}",
239
+ next_action: "Run ace-handbook status to inspect provider skill projections."
240
+ )
241
+ end
242
+
243
+ def check_alias_hygiene(provider_context)
244
+ unless provider_context
245
+ return check(
246
+ id: "alias-readiness",
247
+ kind: "hygiene",
248
+ status: WARN,
249
+ message: "Alias readiness check skipped: provider context unavailable",
250
+ next_action: "Ensure ace-llm is installed and provider discovery succeeds."
251
+ )
252
+ end
253
+
254
+ stale = find_stale_aliases(provider_context)
255
+ if stale.empty?
256
+ return check(
257
+ id: "alias-readiness",
258
+ kind: "hygiene",
259
+ status: PASS,
260
+ message: "Configured model aliases resolve"
261
+ )
262
+ end
263
+
264
+ check(
265
+ id: "alias-readiness",
266
+ kind: "hygiene",
267
+ status: WARN,
268
+ message: "Unsupported alias mappings detected (#{stale.length})",
269
+ next_action: "Update aliases to declared provider models.",
270
+ details: stale.map { |item| "#{item[:provider]}:#{item[:alias]} -> #{item[:resolved]}" }
271
+ )
272
+ rescue => e
273
+ check(
274
+ id: "alias-readiness",
275
+ kind: "hygiene",
276
+ status: WARN,
277
+ message: "Alias readiness check failed: #{e.message}",
278
+ next_action: "Review llm/providers alias configuration."
279
+ )
280
+ end
281
+
282
+ def check_role_health(provider_context)
283
+ unless provider_context
284
+ return check(
285
+ id: "role-defaults",
286
+ kind: "health",
287
+ status: WARN,
288
+ message: "Role default readiness skipped: provider context unavailable",
289
+ next_action: "Ensure ace-llm is installed and provider discovery succeeds."
290
+ )
291
+ end
292
+
293
+ registry = provider_context[:registry]
294
+ role_config = load_role_config
295
+ roles = CORE_ROLES
296
+ problems = []
297
+ targets = []
298
+
299
+ roles.each do |role|
300
+ candidates = role_config.candidates_for(role)
301
+ unless candidates
302
+ problems << "role:#{role} is referenced but not defined"
303
+ next
304
+ end
305
+
306
+ validations = candidates.first(2).map { |candidate| validate_role_candidate(role, candidate, registry) }
307
+ targets.concat(validations.filter_map { |item| item[:target] if item[:status] == PASS })
308
+
309
+ unless validations.any? { |item| item[:status] == PASS }
310
+ problems.concat(validations.reject { |item| item[:status] == PASS }.map { |item| item[:message] })
311
+ problems << "role:#{role} has no ready provider in its first two candidates"
312
+ end
313
+ end
314
+
315
+ if problems.any?
316
+ return check(
317
+ id: "role-defaults",
318
+ status: BLOCKER,
319
+ kind: "health",
320
+ message: "Core role readiness failed (#{problems.uniq.length})",
321
+ next_action: "Update core llm.roles so setup workflows have a usable model path.",
322
+ details: problems.uniq,
323
+ targets: dedupe_targets(targets)
324
+ )
325
+ end
326
+
327
+ check(
328
+ id: "role-defaults",
329
+ kind: "health",
330
+ status: PASS,
331
+ message: "Core role defaults resolve",
332
+ targets: dedupe_targets(targets)
333
+ )
334
+ rescue => e
335
+ check(
336
+ id: "role-defaults",
337
+ kind: "health",
338
+ status: WARN,
339
+ message: "Role default readiness check failed: #{e.message}",
340
+ next_action: "Review llm.roles and provider configuration."
341
+ )
342
+ end
343
+
344
+ def check_role_hygiene(provider_context)
345
+ unless provider_context
346
+ return check(
347
+ id: "role-hygiene",
348
+ kind: "hygiene",
349
+ status: WARN,
350
+ message: "Role hygiene skipped: provider context unavailable",
351
+ next_action: "Ensure ace-llm is installed and provider discovery succeeds."
352
+ )
353
+ end
354
+
355
+ registry = provider_context[:registry]
356
+ role_config = load_role_config
357
+ findings = []
358
+
359
+ used_role_names(role_config).each do |role|
360
+ candidates = role_config.candidates_for(role)
361
+ unless candidates
362
+ findings << "role:#{role} is referenced but not defined"
363
+ next
364
+ end
365
+
366
+ candidates.first(2).each do |candidate|
367
+ validation = validate_role_candidate(role, candidate, registry)
368
+ findings << validation[:message] unless validation[:status] == PASS
369
+ end
370
+ end
371
+
372
+ if findings.any?
373
+ return check(
374
+ id: "role-hygiene",
375
+ kind: "hygiene",
376
+ status: WARN,
377
+ message: "Role/default hygiene findings detected (#{findings.uniq.length})",
378
+ next_action: "Review llm.roles and update stale or misspelled role references.",
379
+ details: findings.uniq
380
+ )
381
+ end
382
+
383
+ check(
384
+ id: "role-hygiene",
385
+ kind: "hygiene",
386
+ status: PASS,
387
+ message: "Role/default hygiene looks clean"
388
+ )
389
+ rescue => e
390
+ check(
391
+ id: "role-hygiene",
392
+ kind: "hygiene",
393
+ status: WARN,
394
+ message: "Role hygiene check failed: #{e.message}",
395
+ next_action: "Review llm.roles and provider configuration."
396
+ )
397
+ end
398
+
399
+ def check_probe_readiness(provider_context, no_probe:, probe:, role_targets:, structural_blockers:, progress_io: nil)
400
+ if no_probe
401
+ return check(
402
+ id: "probe-readiness",
403
+ kind: "health",
404
+ status: SKIP,
405
+ message: "Live provider probes disabled by --no-probe"
406
+ )
407
+ end
408
+ if structural_blockers
409
+ return check(
410
+ id: "probe-readiness",
411
+ kind: "health",
412
+ status: SKIP,
413
+ message: "Live provider probes skipped because setup blockers exist",
414
+ next_action: "Fix blocker checks, then rerun ace-config doctor --probe."
415
+ )
416
+ end
417
+ unless provider_context
418
+ return check(
419
+ id: "probe-readiness",
420
+ kind: "health",
421
+ status: WARN,
422
+ message: "Probe readiness skipped: provider context unavailable",
423
+ next_action: "Verify provider discovery first."
424
+ )
425
+ end
426
+
427
+ targets = order_probe_targets(dedupe_targets(role_targets), provider_context)
428
+ if targets.empty?
429
+ return check(
430
+ id: "probe-readiness",
431
+ kind: "health",
432
+ status: WARN,
433
+ message: "No resolved utility provider targets available for probes",
434
+ next_action: "Define llm.roles._utility or llm.roles.commit, then rerun ace-config doctor."
435
+ )
436
+ end
437
+
438
+ progress = provider_progress(progress_io, targets)
439
+ progress&.start
440
+
441
+ outcomes = run_probe_targets(targets, progress: progress)
442
+ pass_count = outcomes.count { |outcome| outcome[:status] == PASS }
443
+ total_count = outcomes.length
444
+ details = progress_io ? [] : ping_detail_lines(outcomes)
445
+ if pass_count == total_count && total_count.positive?
446
+ return check(
447
+ id: "probe-readiness",
448
+ kind: "health",
449
+ status: PASS,
450
+ message: "Utility provider pings completed (#{pass_count}/#{total_count} passed)",
451
+ details: details,
452
+ outcomes: outcomes
453
+ )
454
+ end
455
+
456
+ if pass_count.positive?
457
+ return check(
458
+ id: "probe-readiness",
459
+ kind: "health",
460
+ status: WARN,
461
+ message: "Utility provider pings partially completed (#{pass_count}/#{total_count} passed)",
462
+ details: details,
463
+ outcomes: outcomes,
464
+ next_action: "At least one utility provider works; inspect failed providers if you need full redundancy."
465
+ )
466
+ end
467
+
468
+ next_actions = outcomes.filter_map { |o| o[:next_action] }.uniq
469
+ check(
470
+ id: "probe-readiness",
471
+ kind: "health",
472
+ status: WARN,
473
+ message: "Utility provider pings failed (0/#{total_count} passed)",
474
+ next_action: next_actions.first || "Authenticate at least one provider and rerun ace-config doctor.",
475
+ outcomes: outcomes,
476
+ details: details.empty? ? next_actions : details
477
+ )
478
+ end
479
+
480
+ def run_probe_targets(targets, progress: nil)
481
+ outcomes = Array.new(targets.length)
482
+ queue = Queue.new
483
+ targets.each_with_index { |target, index| queue << [index, target] }
484
+
485
+ threads = targets.length.times.map do
486
+ Thread.new do
487
+ loop do
488
+ index, target = queue.pop(true)
489
+ outcome = run_probe_target(target)
490
+ outcomes[index] = outcome
491
+ progress&.finish(index, outcome)
492
+ rescue ThreadError
493
+ break
494
+ end
495
+ end
496
+ end
497
+ threads.each(&:join)
498
+
499
+ outcomes.compact
500
+ end
501
+
502
+ def run_probe_target(target)
503
+ selector = target[:selector] || [target[:provider], target[:model]].compact.join(":")
504
+ label = target[:label] || selector
505
+ timeout_seconds = target[:timeout_seconds] || 15
506
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
507
+ out, err, status = Open3.capture3(
508
+ "ace-llm",
509
+ label.to_s,
510
+ "ping",
511
+ "--no-fallback",
512
+ "--quiet",
513
+ "--timeout",
514
+ timeout_seconds.to_s,
515
+ "--max-tokens",
516
+ "4"
517
+ )
518
+ if status.success?
519
+ {
520
+ status: PASS,
521
+ provider: target[:provider].to_s,
522
+ label: label.to_s,
523
+ selector: selector.to_s,
524
+ provider_kind: target[:provider_kind],
525
+ timeout_seconds: timeout_seconds,
526
+ elapsed_ms: elapsed_ms(started_at)
527
+ }
528
+ else
529
+ failure_text = "#{out}\n#{err}"
530
+ {
531
+ status: WARN,
532
+ provider: target[:provider].to_s,
533
+ label: label.to_s,
534
+ selector: selector.to_s,
535
+ provider_kind: target[:provider_kind],
536
+ timeout_seconds: timeout_seconds,
537
+ failure_type: timeout_error?(failure_text) ? "timeout" : "error",
538
+ next_action: ping_next_action(failure_text, selector: selector)
539
+ }
540
+ end
541
+ rescue => e
542
+ {
543
+ status: WARN,
544
+ provider: target[:provider].to_s,
545
+ label: target[:label].to_s,
546
+ selector: selector.to_s,
547
+ provider_kind: target[:provider_kind],
548
+ timeout_seconds: timeout_seconds,
549
+ failure_type: timeout_error?(e.message) ? "timeout" : "error",
550
+ next_action: ping_next_action(e.message, selector: selector)
551
+ }
552
+ end
553
+
554
+ def elapsed_ms(started_at)
555
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
556
+ end
557
+
558
+ def ping_detail_lines(outcomes)
559
+ outcomes.map do |outcome|
560
+ elapsed = outcome[:elapsed_ms] ? " in #{outcome[:elapsed_ms]}ms" : ""
561
+ target = target_display(outcome)
562
+ if outcome[:status] == PASS
563
+ "#{target} responded#{elapsed}"
564
+ elsif outcome[:failure_type] == "timeout"
565
+ "#{target} timed out after #{outcome[:timeout_seconds]}s"
566
+ else
567
+ "#{target} failed"
568
+ end
569
+ end
570
+ end
571
+
572
+ def provider_progress(io, targets)
573
+ return nil unless io
574
+
575
+ ProviderProgress.new(self, io, targets)
576
+ end
577
+
578
+ def ping_next_action(text, selector: nil)
579
+ if auth_related_error?(text)
580
+ "Authenticate at least one utility provider and rerun."
581
+ else
582
+ target = selector ? " #{selector}" : ""
583
+ "Run ace-llm#{target} \"ping\" --no-fallback to inspect provider setup."
584
+ end
585
+ end
586
+
587
+ def load_provider_context
588
+ require "ace/llm"
589
+ require "ace/llm/molecules/client_registry"
590
+
591
+ registry = Ace::LLM::Molecules::ClientRegistry.new
592
+ {
593
+ registry: registry,
594
+ providers: registry.list_providers_with_status,
595
+ aliases: registry.available_aliases
596
+ }
597
+ rescue LoadError, StandardError
598
+ nil
599
+ end
600
+
601
+ def load_role_config
602
+ require "ace/llm"
603
+ require "ace/llm/models/role_config"
604
+
605
+ Ace::LLM::Models::RoleConfig.from_hash(Ace::LLM.configuration.get("llm.roles"))
606
+ end
607
+
608
+ def used_role_names(_role_config)
609
+ (CORE_ROLES + role_references_from_config_files).uniq.sort
610
+ end
611
+
612
+ def utility_provider_targets(provider_context)
613
+ return [] unless provider_context
614
+
615
+ registry = provider_context[:registry]
616
+ role_config = load_role_config
617
+ utility_role = UTILITY_ROLE_GROUPS.find { |role| role_config.candidates_for(role) }
618
+ candidates = Array(role_config.candidates_for(utility_role)) + Array(role_config.candidates_for("commit"))
619
+ candidates.filter_map do |candidate|
620
+ parse_role_provider_target("utility", candidate, registry)
621
+ end
622
+ rescue
623
+ []
624
+ end
625
+
626
+ def parse_role_provider_target(role, candidate, registry)
627
+ require "ace/llm"
628
+ require "ace/llm/molecules/llm_alias_resolver"
629
+ require "ace/llm/molecules/provider_model_parser"
630
+
631
+ alias_resolver = Ace::LLM::Molecules::LlmAliasResolver.new(registry: registry)
632
+ parser = Ace::LLM::Molecules::ProviderModelParser.new(alias_resolver: alias_resolver, registry: registry)
633
+ parsed = parser.parse(candidate.to_s)
634
+ return nil if parsed.invalid?
635
+
636
+ provider = parsed.provider.to_s
637
+ model = parsed.model.to_s
638
+ selector = model.empty? ? provider : "#{provider}:#{model}"
639
+ {role: role.to_s, provider: provider, model: model, selector: selector, label: candidate.to_s}
640
+ end
641
+
642
+ def order_probe_targets(targets, provider_context)
643
+ targets.map.with_index do |target, index|
644
+ provider_kind = provider_kind(target[:provider], provider_context)
645
+ timeout_seconds = (provider_kind == "cli") ? 30 : 15
646
+ target.merge(provider_kind: provider_kind, timeout_seconds: timeout_seconds, _order: index)
647
+ end.sort_by do |target|
648
+ [(target[:provider_kind] == "api") ? 0 : 1, target[:_order]]
649
+ end.map { |target| target.reject { |key, _| key == :_order } }
650
+ end
651
+
652
+ def provider_kind(provider_name, provider_context)
653
+ registry = provider_context&.fetch(:registry, nil)
654
+ provider_config = registry&.respond_to?(:get_provider) ? registry.get_provider(provider_name) : nil
655
+ klass = provider_config&.fetch("class", "").to_s
656
+ gem_name = provider_config&.fetch("gem", "").to_s
657
+ return "cli" if gem_name == PROVIDER_GEM || klass.include?("Providers::CLI")
658
+
659
+ "api"
660
+ rescue
661
+ "api"
662
+ end
663
+
664
+ def role_references_from_config_files
665
+ config_files.flat_map do |path|
666
+ data = YAML.safe_load_file(path, aliases: true)
667
+ extract_role_references(data)
668
+ rescue Psych::Exception, Errno::ENOENT, Errno::EACCES
669
+ []
670
+ end
671
+ end
672
+
673
+ def config_files
674
+ root = project_root
675
+ patterns = [
676
+ File.join(root, ".ace", "**", "*.yml"),
677
+ File.join(root, ".ace", "**", "*.yaml"),
678
+ File.join(root, "*", ".ace-defaults", "**", "*.yml"),
679
+ File.join(root, "*", ".ace-defaults", "**", "*.yaml")
680
+ ]
681
+ patterns.flat_map { |pattern| Dir.glob(pattern) }.select { |path| File.file?(path) }.uniq.sort
682
+ end
683
+
684
+ def extract_role_references(value)
685
+ case value
686
+ when Hash
687
+ value.values.flat_map { |nested| extract_role_references(nested) }
688
+ when Array
689
+ value.flat_map { |nested| extract_role_references(nested) }
690
+ when String
691
+ value.scan(ROLE_REFERENCE_PATTERN).flatten
692
+ else
693
+ []
694
+ end
695
+ end
696
+
697
+ def validate_role_candidate(role, candidate, registry)
698
+ require "ace/llm"
699
+ require "ace/llm/molecules/llm_alias_resolver"
700
+ require "ace/llm/molecules/provider_model_parser"
701
+
702
+ alias_resolver = Ace::LLM::Molecules::LlmAliasResolver.new(registry: registry)
703
+ parser = Ace::LLM::Molecules::ProviderModelParser.new(alias_resolver: alias_resolver, registry: registry)
704
+ parsed = parser.parse(candidate.to_s)
705
+ if parsed.invalid?
706
+ return {
707
+ status: BLOCKER,
708
+ message: "role:#{role} candidate #{candidate} is invalid: #{parsed.error}"
709
+ }
710
+ end
711
+
712
+ provider = parsed.provider.to_s
713
+ model = parsed.model.to_s
714
+ unless Array(registry.models_for_provider(provider)).map(&:to_s).include?(model)
715
+ return {
716
+ status: BLOCKER,
717
+ message: "role:#{role} candidate #{candidate} resolves to unsupported model #{provider}:#{model}"
718
+ }
719
+ end
720
+
721
+ target = {role: role.to_s, provider: provider, model: model, selector: "#{provider}:#{model}"}
722
+ unless registry.provider_available?(provider)
723
+ return {
724
+ status: WARN,
725
+ message: "role:#{role} candidate #{candidate} provider #{provider} is unavailable",
726
+ target: target
727
+ }
728
+ end
729
+
730
+ if registry.provider_api_key_required?(provider) && !registry.provider_api_key_present?(provider)
731
+ return {
732
+ status: WARN,
733
+ message: "role:#{role} candidate #{candidate} provider #{provider} is missing credentials",
734
+ target: target
735
+ }
736
+ end
737
+
738
+ {status: PASS, target: target}
739
+ end
740
+
741
+ def dedupe_targets(targets)
742
+ seen = {}
743
+ targets.each_with_object([]) do |target, result|
744
+ provider = target[:provider].to_s
745
+ next if seen[provider]
746
+
747
+ seen[provider] = true
748
+ result << target
749
+ end
750
+ end
751
+
752
+ def target_display(target)
753
+ label = target[:label].to_s
754
+ selector = target[:selector].to_s
755
+ label = selector if label.empty?
756
+ selector.empty? || selector == label ? label : "#{label} (#{selector})"
757
+ end
758
+
759
+ def format_probe_line(target, status:, color: false, elapsed_ms: nil)
760
+ glyph = status_glyph(status)
761
+ glyph = colorize(glyph, status) if color
762
+ suffix = if status == PASS && elapsed_ms
763
+ " in #{elapsed_ms}ms"
764
+ elsif status == WARN && target[:failure_type] == "timeout"
765
+ " timed out after #{target[:timeout_seconds]}s"
766
+ elsif status == WARN
767
+ " failed"
768
+ else
769
+ ""
770
+ end
771
+ "#{glyph} #{target_display(target)}#{suffix}"
772
+ end
773
+
774
+ def status_glyph(status)
775
+ STATUS_GLYPHS.fetch(status, STATUS_GLYPHS[WARN])
776
+ end
777
+
778
+ def colorize(value, status)
779
+ "#{STATUS_COLORS.fetch(status, "")}#{value}#{ANSI_RESET}"
780
+ end
781
+
782
+ class ProviderProgress
783
+ def initialize(doctor, io, targets)
784
+ @doctor = doctor
785
+ @io = io
786
+ @targets = targets
787
+ @tty = io.respond_to?(:tty?) && io.tty?
788
+ @line_count = 0
789
+ @mutex = Mutex.new
790
+ end
791
+
792
+ def start
793
+ @io.puts "RUN Utility provider pings running (0/#{@targets.length} passed)"
794
+ @targets.each do |target|
795
+ @io.puts " #{format_line(target, status: "running")}"
796
+ end
797
+ @line_count = @targets.length
798
+ flush
799
+ end
800
+
801
+ def finish(index, outcome)
802
+ @mutex.synchronize do
803
+ if @tty
804
+ rewrite_line(index, outcome)
805
+ else
806
+ @io.puts " #{format_line(outcome, status: outcome[:status], elapsed_ms: outcome[:elapsed_ms])}"
807
+ end
808
+ flush
809
+ end
810
+ end
811
+
812
+ private
813
+
814
+ def rewrite_line(index, outcome)
815
+ return append_line(outcome) if @line_count.zero?
816
+
817
+ up = @line_count - index
818
+ @io.print "\e[#{up}A" if up.positive?
819
+ @io.print "\r\e[2K #{format_line(outcome, status: outcome[:status], elapsed_ms: outcome[:elapsed_ms])}\n"
820
+ down = up - 1
821
+ @io.print "\e[#{down}B" if down.positive?
822
+ end
823
+
824
+ def append_line(outcome)
825
+ @io.puts " #{format_line(outcome, status: outcome[:status], elapsed_ms: outcome[:elapsed_ms])}"
826
+ end
827
+
828
+ def format_line(target, status:, elapsed_ms: nil)
829
+ @doctor.send(:format_probe_line, target, status: status, color: @tty, elapsed_ms: elapsed_ms)
830
+ end
831
+
832
+ def flush
833
+ @io.flush if @io.respond_to?(:flush)
834
+ end
835
+ end
836
+
837
+ def find_stale_aliases(provider_context)
838
+ providers = provider_context[:providers]
839
+ aliases = provider_context[:aliases] || {}
840
+ provider_models = providers.to_h do |provider|
841
+ [provider[:name].to_s, Array(provider[:models]).map(&:to_s)]
842
+ end
843
+
844
+ stale = []
845
+
846
+ model_aliases = aliases[:model] || aliases["model"] || {}
847
+ model_aliases.each do |provider_name, mapping|
848
+ (mapping || {}).each do |alias_name, model_name|
849
+ next if valid_provider_model_target?(provider_models, provider_name, model_name)
850
+
851
+ stale << {provider: provider_name.to_s, alias: alias_name.to_s, resolved: model_name.to_s}
852
+ end
853
+ end
854
+
855
+ global_aliases = aliases[:global] || aliases["global"] || {}
856
+ registry = provider_context[:registry]
857
+ global_aliases.each do |alias_name, target|
858
+ provider_name, model_name = parse_provider_model_target(resolve_alias_target(registry, target))
859
+ next unless provider_name && model_name
860
+ next if valid_provider_model_target?(provider_models, provider_name, model_name)
861
+
862
+ stale << {provider: provider_name, alias: alias_name.to_s, resolved: model_name}
863
+ end
864
+
865
+ stale
866
+ end
867
+
868
+ def append_check(checks, check_row, stream: false, io: nil, skip_ids: [])
869
+ checks << check_row
870
+ output_progress_check(check_row, io: io) if stream && io && !Array(skip_ids).include?(check_row[:id])
871
+ check_row
872
+ end
873
+
874
+ def output_progress_check(check_row, io:)
875
+ return if check_row[:kind] == "hygiene"
876
+
877
+ io.puts "#{check_row[:status].upcase} #{check_row[:message]}"
878
+ flush_io(io)
879
+ end
880
+
881
+ def build_summary(checks)
882
+ health_checks = checks.select { |check_row| check_row[:kind] == "health" }
883
+ info_checks = checks.select { |check_row| check_row[:kind] == "info" }
884
+ hygiene_checks = checks.select { |check_row| check_row[:kind] == "hygiene" }
885
+ {
886
+ generated_at: Time.now.utc.iso8601,
887
+ blocker_count: health_checks.count { |check_row| check_row[:status] == BLOCKER },
888
+ warning_count: health_checks.count { |check_row| check_row[:status] == WARN },
889
+ info_count: info_checks.length,
890
+ health: {
891
+ blocker_count: health_checks.count { |check_row| check_row[:status] == BLOCKER },
892
+ warning_count: health_checks.count { |check_row| check_row[:status] == WARN }
893
+ },
894
+ info: {
895
+ count: info_checks.length
896
+ },
897
+ hygiene: {
898
+ finding_count: hygiene_finding_count(hygiene_checks),
899
+ warning_count: hygiene_checks.count { |check_row| check_row[:status] == WARN },
900
+ blocker_count: hygiene_checks.count { |check_row| check_row[:status] == BLOCKER }
901
+ },
902
+ checks: checks
903
+ }
904
+ end
905
+
906
+ def build_stats(checks)
907
+ health_checks = checks.select { |check_row| check_row[:kind] == "health" }
908
+ info_checks = checks.select { |check_row| check_row[:kind] == "info" }
909
+ probe_check = health_checks.find { |check_row| check_row[:id] == "probe-readiness" }
910
+ provider_outcomes = Array(probe_check&.fetch(:outcomes, []))
911
+ config_check = info_checks.find { |check_row| check_row[:id] == "config-defaults" }
912
+ skill_sync_check = health_checks.find { |check_row| check_row[:id] == "skill-sync" }
913
+ {
914
+ health_checks: health_checks.length,
915
+ info_checks: info_checks.length,
916
+ provider_targets: provider_outcomes.length,
917
+ provider_passed: provider_outcomes.count { |outcome| outcome[:status] == PASS },
918
+ hygiene_findings: hygiene_finding_count(checks.select { |check_row| check_row[:kind] == "hygiene" }),
919
+ config_defaults: config_check&.fetch(:summary, nil),
920
+ skill_sync: skill_sync_check&.fetch(:skill_sync, nil)
921
+ }
922
+ end
923
+
924
+ def health_blocking?(checks)
925
+ checks.any? { |check_row| check_row[:kind] == "health" && check_row[:status] == BLOCKER }
926
+ end
927
+
928
+ def flush_io(io)
929
+ io.flush if io.respond_to?(:flush)
930
+ end
931
+
932
+ def hygiene_finding_count(checks)
933
+ checks.sum do |check_row|
934
+ details = Array(check_row[:details])
935
+ if details.any?
936
+ details.length
937
+ elsif check_row[:status] == PASS
938
+ 0
939
+ else
940
+ 1
941
+ end
942
+ end
943
+ end
944
+
945
+ def check(id:, kind: "health", status:, message:, next_action: nil, **extra)
946
+ {id: id, kind: kind, status: status, message: message, next_action: next_action}.merge(extra)
947
+ end
948
+
949
+ def discovery_next_action(stderr_output)
950
+ text = stderr_output.to_s
951
+ if text.include?(PROVIDER_GEM)
952
+ "Install #{PROVIDER_GEM} and rerun ace-llm --list-providers."
953
+ else
954
+ "Run ace-llm --list-providers to inspect provider setup, then rerun ace-config doctor."
955
+ end
956
+ end
957
+
958
+ def auth_related_error?(text)
959
+ value = text.to_s.downcase
960
+ value.include?("credential") ||
961
+ value.include?("api key") ||
962
+ value.include?("auth") ||
963
+ value.include?("login")
964
+ end
965
+
966
+ def timeout_error?(text)
967
+ value = text.to_s.downcase
968
+ value.include?("timed out") || value.include?("timeout")
969
+ end
970
+
971
+ def collect_config_defaults_summary
972
+ root = project_root
973
+ details = []
974
+ customized = 0
975
+ default = 0
976
+ files = 0
977
+
978
+ Models::ConfigTemplates.all_gems.each do |gem_name|
979
+ source_dir = Models::ConfigTemplates.example_dir_for(gem_name)
980
+ next unless source_dir && Dir.exist?(source_dir)
981
+
982
+ gem_files = Dir.glob(File.join(source_dir, "**", "*")).select { |path| File.file?(path) }
983
+ next if gem_files.empty?
984
+
985
+ gem_customized = 0
986
+ gem_default = 0
987
+ gem_files.each do |source_file|
988
+ relative = Pathname.new(source_file).relative_path_from(Pathname.new(source_dir)).to_s
989
+ target_file = File.join(root, ".ace", relative)
990
+ files += 1
991
+ if File.exist?(target_file) && File.read(source_file) != File.read(target_file)
992
+ customized += 1
993
+ gem_customized += 1
994
+ else
995
+ default += 1
996
+ gem_default += 1
997
+ end
998
+ rescue
999
+ default += 1
1000
+ gem_default += 1
1001
+ end
1002
+
1003
+ details << "#{gem_name}: #{gem_customized} customized, #{gem_default} default"
1004
+ end
1005
+
1006
+ {files: files, customized: customized, default: default, details: details}
1007
+ end
1008
+
1009
+ def skill_sync_detail(entry)
1010
+ provider = entry.fetch("provider")
1011
+ expected = entry.fetch("expected", 0).to_i
1012
+ in_sync = entry.fetch("in_sync", 0).to_i
1013
+ missing = entry.fetch("missing", 0).to_i
1014
+ outdated = entry.fetch("outdated", 0).to_i
1015
+ extra = entry.fetch("extra", 0).to_i
1016
+ "#{provider}: #{in_sync}/#{expected} in sync, #{missing} missing, #{outdated} outdated, #{extra} extra"
1017
+ end
1018
+
1019
+ def project_root
1020
+ Ace::Support::Config.find_project_root(start_path: Dir.pwd) || Dir.pwd
1021
+ end
1022
+
1023
+ def gitignore_entry_present?(content, entry)
1024
+ target = canonical_ignore_token(entry)
1025
+ content.each_line.any? do |line|
1026
+ normalized = canonical_ignore_token(line)
1027
+ next false if normalized.nil?
1028
+
1029
+ normalized == target || normalized.start_with?("#{target}/")
1030
+ end
1031
+ end
1032
+
1033
+ def valid_provider_model_target?(provider_models, provider_name, model_name)
1034
+ expected = provider_models[provider_name.to_s] || []
1035
+ !expected.empty? && expected.include?(model_name.to_s)
1036
+ end
1037
+
1038
+ def resolve_alias_target(registry, target)
1039
+ value = target.to_s
1040
+ return value unless registry&.respond_to?(:resolve_alias)
1041
+
1042
+ registry.resolve_alias(value).to_s
1043
+ rescue StandardError
1044
+ value
1045
+ end
1046
+
1047
+ def parse_provider_model_target(value)
1048
+ parts = value.to_s.split(":", 2)
1049
+ return [nil, nil] if parts.length != 2
1050
+
1051
+ provider, model = parts
1052
+ return [nil, nil] if provider.to_s.empty? || model.to_s.empty?
1053
+
1054
+ [provider.to_s, model.to_s]
1055
+ end
1056
+
1057
+ def canonical_ignore_token(value)
1058
+ token = value.to_s.strip
1059
+ return nil if token.empty? || token.start_with?("#") || token.start_with?("!")
1060
+
1061
+ token = token.delete_prefix("/")
1062
+ token = token.delete_suffix("/")
1063
+ token
1064
+ end
1065
+ end
1066
+ end
1067
+ end
1068
+ end
1069
+ end