ace-support-config 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f7f5cddbf50a8da6f9f42cc644f193cd9a2898757a3417849fc4104546c8ba2
4
- data.tar.gz: 54c887961b4c3473a7a4be0bc5383b2ab4975d2d779c86e98c02e180a298c082
3
+ metadata.gz: 9b56e7f280832462c646dce84cf5565c6ac719dfaa57e3ece2b9974f2561b0a4
4
+ data.tar.gz: 0bf487c765f752a14ea748f06c951a71a7aab45be83126dd42307da23e8cfebd
5
5
  SHA512:
6
- metadata.gz: 5a3291da675776cd51aa9f5d888bc8f001c3f0221206c684551ec0bfaa3cbdfe94361ed3c8a56826bce3cd838a172873cdf31afa95202a4ea0dc6278afce111d
7
- data.tar.gz: 570b40415d3a4b63b3762d09fea0b43c74628dd91f286818215aaa388371d26dc586f4ab00316b6c781ae38e1555477cf07a03d0340fa2e4f8d1b9ec7b0463a9
6
+ metadata.gz: 0f2e4c7389588f2b2e894cfb7730e6a226dbbfa36d5b9ba8bd55943e52195f28baca4bc493fed748bfba2388f4e5dfc1824aca28220219adf353ad41e5c083fc
7
+ data.tar.gz: f58924e6b836b963734ef369f1d548cf36a61144a07dc2d0779ed00f4c7f548a7204992badab4be93299a6772bc67b51e20c37497a7c0e57a95329ef5f8483e5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.0] - 2026-08-13
11
+ ### Added
12
+ - Manage expiring drift acknowledgements
13
+ - Add profile-aware policy checks to doctor
14
+ - Report profile-aware configuration drift in doctor
15
+ ### Technical
16
+ - Streamline bootstrap templates around principles-first guidance
17
+
10
18
  ## [0.17.1] - 2026-06-30
11
19
 
12
20
  ### Changed
@@ -168,7 +168,19 @@ module Ace
168
168
  end
169
169
 
170
170
  def run_doctor(argv)
171
- options = {json: false, no_probe: false, probe: false, hygiene: false, verbose: false, no_color: false, quiet: false}
171
+ options = {
172
+ json: false,
173
+ no_probe: false,
174
+ probe: false,
175
+ hygiene: false,
176
+ verbose: false,
177
+ no_color: false,
178
+ quiet: false,
179
+ recommendations: false,
180
+ profile: nil,
181
+ strict: false,
182
+ check_updates: false
183
+ }
172
184
 
173
185
  parser = OptionParser.new do |opts|
174
186
  opts.banner = <<~BANNER.chomp
@@ -181,6 +193,10 @@ module Ace
181
193
  OPTIONS
182
194
  BANNER
183
195
  opts.on("--json", "Output checks as JSON") { options[:json] = true }
196
+ opts.on("--recommendations", "Enable recommendation mode") { options[:recommendations] = true }
197
+ opts.on("--profile PROFILE", "Select profile (minimal, application, ace-development)") { |p| options[:profile] = p }
198
+ opts.on("--strict", "Exit nonzero on blocker or warning recommendations") { options[:strict] = true }
199
+ opts.on("--check-updates", "Check for package/recommendation updates") { options[:check_updates] = true }
184
200
  opts.on("--hygiene", "Show full hygiene findings") { options[:hygiene] = true }
185
201
  opts.on("-v", "--verbose", "Show full diagnostic detail") { options[:verbose] = true }
186
202
  opts.on("-q", "--quiet", "Suppress output; use exit status only") { options[:quiet] = true }
@@ -195,15 +211,28 @@ module Ace
195
211
 
196
212
  parser.parse!(argv)
197
213
 
198
- exit_code = Organisms::SetupDoctor.new.run(
199
- json: options[:json],
200
- no_probe: options[:no_probe],
201
- probe: options[:probe],
202
- hygiene: options[:hygiene],
203
- verbose: options[:verbose],
204
- colors: !options[:no_color],
205
- quiet: options[:quiet]
206
- )
214
+ doctor = Organisms::SetupDoctor.new
215
+
216
+ if options[:recommendations]
217
+ exit_code = doctor.run_recommendations(
218
+ profile: options[:profile],
219
+ strict: options[:strict],
220
+ check_updates: options[:check_updates],
221
+ json: options[:json],
222
+ quiet: options[:quiet]
223
+ )
224
+ else
225
+ exit_code = doctor.run(
226
+ json: options[:json],
227
+ no_probe: options[:no_probe],
228
+ probe: options[:probe],
229
+ hygiene: options[:hygiene],
230
+ verbose: options[:verbose],
231
+ colors: !options[:no_color],
232
+ quiet: options[:quiet]
233
+ )
234
+ end
235
+
207
236
  exit(exit_code) if exit_code.positive?
208
237
  end
209
238
 
@@ -51,6 +51,56 @@ module Ace
51
51
  end
52
52
  end
53
53
 
54
+ def self.format_recommendations(profile, findings, strict: false)
55
+ output = []
56
+ output << "\nšŸ„ Configuration Recommendations (Profile: #{profile})"
57
+ output << "=" * 50
58
+ output << ""
59
+
60
+ # Separate acknowledged from visible findings
61
+ visible = findings.reject { |f| f[:severity] == "acknowledged" }
62
+ acknowledged_count = findings.count { |f| f[:severity] == "acknowledged" }
63
+
64
+ if visible.empty? && acknowledged_count.zero?
65
+ output << "āœ… No recommendations found - configuration matches profile '#{profile}'."
66
+ elsif visible.empty?
67
+ output << "āœ… All findings acknowledged (#{acknowledged_count} active)."
68
+ else
69
+ visible.each_with_index do |f, idx|
70
+ glyph = case f[:severity]
71
+ when "blocker" then "āŒ"
72
+ when "warning" then "āš ļø"
73
+ when "recommendation" then "šŸ’”"
74
+ else "ā„¹ļø"
75
+ end
76
+ output << "#{idx + 1}. #{glyph} [#{f[:severity].upcase}] #{f[:id]}"
77
+ output << " Source: #{f[:resolved_source]} | Version: #{f[:version]}"
78
+ output << " Current: #{f[:current_value]}"
79
+ output << " Recommended: #{f[:recommended_value]}"
80
+ output << " Rationale: #{f[:rationale]}" if f[:rationale]
81
+ output << " Next Action: #{f[:next_action]}" if f[:next_action]
82
+ if f[:acknowledgement_expired] && f[:acknowledgement]
83
+ ack = f[:acknowledgement]
84
+ output << " Prior Acknowledgement Expired: #{ack["rationale"]} (by #{ack["actor"]})"
85
+ end
86
+ output << ""
87
+ end
88
+ if acknowledged_count.positive?
89
+ output << " (#{acknowledged_count} finding#{"s" if acknowledged_count > 1} suppressed by active acknowledgement#{"s" if acknowledged_count > 1})"
90
+ output << ""
91
+ end
92
+ end
93
+
94
+ output << "=" * 50
95
+ has_failures = findings.any? { |f| %w[blocker warning].include?(f[:severity]) }
96
+ if strict && has_failures
97
+ output << "\e[31mRecommendation check failed under strict mode\e[0m"
98
+ else
99
+ output << "\e[32mRecommendation check completed\e[0m"
100
+ end
101
+ output.join("\n")
102
+ end
103
+
54
104
  def initialize(results, hygiene:, verbose:, colors:)
55
105
  @results = results
56
106
  @hygiene = hygiene
@@ -89,6 +89,223 @@ module Ace
89
89
  health_blocking?(checks) ? 1 : 0
90
90
  end
91
91
 
92
+ VALID_PROFILES = %w[minimal application ace-development].freeze
93
+
94
+ def run_recommendations(profile: nil, strict: false, check_updates: false, json: false, quiet: false, io: $stdout)
95
+ resolved_profile = resolve_profile(profile)
96
+ unless VALID_PROFILES.include?(resolved_profile)
97
+ io.puts "Error: Unknown profile '#{profile}'. Accepted profiles are: #{VALID_PROFILES.join(", ")}"
98
+ return 1
99
+ end
100
+
101
+ findings = evaluate_recommendations(profile: resolved_profile, check_updates: check_updates)
102
+
103
+ has_strict_failures = findings.any? { |f| %w[blocker warning].include?(f[:severity]) }
104
+ exit_code = (strict && has_strict_failures) ? 1 : 0
105
+
106
+ unless quiet
107
+ if json
108
+ result = {
109
+ schema_version: "1.0",
110
+ profile: resolved_profile,
111
+ strict: strict,
112
+ valid: !has_strict_failures,
113
+ findings: findings
114
+ }
115
+ io.puts JSON.pretty_generate(result)
116
+ else
117
+ io.puts Molecules::SetupDoctorReporter.format_recommendations(resolved_profile, findings, strict: strict)
118
+ end
119
+ end
120
+
121
+ exit_code
122
+ end
123
+
124
+ def resolve_profile(cli_profile)
125
+ return cli_profile if cli_profile && !cli_profile.to_s.strip.empty?
126
+
127
+ root = project_root
128
+ proj_config = [
129
+ File.join(root, ".ace", "config", "config.yml"),
130
+ File.join(root, ".ace", "config.yml")
131
+ ].find { |f| File.exist?(f) }
132
+
133
+ if proj_config
134
+ begin
135
+ data = YAML.safe_load_file(proj_config, aliases: true)
136
+ p = data&.dig("profile") || data&.dig("config", "profile")
137
+ return p.to_s if p && !p.to_s.strip.empty?
138
+ rescue
139
+ # fallback
140
+ end
141
+ end
142
+
143
+ "minimal"
144
+ end
145
+
146
+ def evaluate_recommendations(profile:, check_updates: false)
147
+ findings = []
148
+ root = project_root
149
+ version = "0.38.0"
150
+
151
+ # Finding 1: Artifact Hygiene (all profiles)
152
+ gitignore_path = File.join(root, ".gitignore")
153
+ if !File.exist?(gitignore_path) || !gitignore_entry_present?(File.read(gitignore_path), ".ace-local/")
154
+ findings << {
155
+ id: "rec-artifact-hygiene",
156
+ severity: "blocker",
157
+ profile: profile,
158
+ evidence: ".ace-local/ is missing from .gitignore",
159
+ resolved_source: "project",
160
+ current_value: File.exist?(gitignore_path) ? "missing .ace-local/" : "missing .gitignore",
161
+ recommended_value: ".ace-local/ in .gitignore",
162
+ rationale: "Prevents committing transient agent state",
163
+ next_action: "Add .ace-local/ to .gitignore",
164
+ version: version
165
+ }
166
+ end
167
+
168
+ # Finding 2: Provider Package (all profiles)
169
+ installed = begin
170
+ Gem::Specification.find_all_by_name(PROVIDER_GEM).any?
171
+ rescue
172
+ false
173
+ end
174
+ unless installed
175
+ findings << {
176
+ id: "rec-provider-package",
177
+ severity: "warning",
178
+ profile: profile,
179
+ evidence: "#{PROVIDER_GEM} gem is not installed",
180
+ resolved_source: "package_default",
181
+ current_value: "not_installed",
182
+ recommended_value: "#{PROVIDER_GEM} installed",
183
+ rationale: "Required for LLM provider discovery and execution",
184
+ next_action: "gem install #{PROVIDER_GEM}",
185
+ version: version
186
+ }
187
+ end
188
+
189
+ # Finding 3: Profile declaration (minimal profile)
190
+ if profile == "minimal"
191
+ findings << {
192
+ id: "rec-profile-config",
193
+ severity: "info",
194
+ profile: profile,
195
+ evidence: "Project relies on minimal fallback profile",
196
+ resolved_source: "package_default",
197
+ current_value: "minimal (fallback)",
198
+ recommended_value: "explicit profile in .ace/config/config.yml",
199
+ rationale: "Explicit profile selection enables profile-aware lifecycle checks",
200
+ next_action: "Add 'profile: application' to .ace/config/config.yml",
201
+ version: version
202
+ }
203
+ end
204
+
205
+ # Finding 4: Profile-specific checks for application & ace-development
206
+ if %w[application ace-development].include?(profile)
207
+ wt_config = File.join(root, ".ace", "git", "worktree.yml")
208
+ unless File.exist?(wt_config)
209
+ findings << {
210
+ id: "rec-worktree-bootstrap",
211
+ severity: "warning",
212
+ profile: profile,
213
+ evidence: ".ace/git/worktree.yml is missing",
214
+ resolved_source: "package_default",
215
+ current_value: "not_configured",
216
+ recommended_value: "project worktree policy configured",
217
+ rationale: "#{profile} profile requires explicit worktree preparation policy",
218
+ next_action: "ace-git-worktree config init",
219
+ version: version
220
+ }
221
+ end
222
+
223
+ # Guidance check
224
+ agents_md = File.join(root, "AGENTS.md")
225
+ tools_md = File.join(root, "docs", "tools.md")
226
+ unless File.exist?(agents_md) && File.exist?(tools_md)
227
+ findings << {
228
+ id: "rec-agent-guidance",
229
+ severity: "warning",
230
+ profile: profile,
231
+ evidence: "Agent engineering guidance is incomplete or unanchored",
232
+ resolved_source: "project",
233
+ current_value: File.exist?(agents_md) ? "missing docs/tools.md" : "missing AGENTS.md",
234
+ recommended_value: "AGENTS.md and docs/tools.md present",
235
+ rationale: "Provides structured engineering principles and agent practice guidance",
236
+ next_action: "Run ace-config sync ace-support-core --force",
237
+ version: version
238
+ }
239
+ end
240
+ end
241
+
242
+ # Finding 5: Application profile policy checks (strict application workflow validation)
243
+ if profile == "application"
244
+ # Check worktree root path safety if config exists
245
+ wt_config = File.join(root, ".ace", "git", "worktree.yml")
246
+ if File.exist?(wt_config)
247
+ begin
248
+ wt_data = YAML.safe_load_file(wt_config, aliases: true)
249
+ root_p = wt_data&.dig("git", "worktree", "root_path") || wt_data&.dig("root_path")
250
+ if root_p && (root_p.start_with?("/") || root_p.include?(".."))
251
+ findings << {
252
+ id: "rec-worktree-policy",
253
+ severity: "warning",
254
+ profile: "application",
255
+ evidence: "Worktree root_path '#{root_p}' escapes repository boundary",
256
+ resolved_source: "project",
257
+ current_value: root_p,
258
+ recommended_value: ".ace-wt (repository-local)",
259
+ rationale: "Application profile requires repository-local or common-root worktrees",
260
+ next_action: "Set git.worktree.root_path to .ace-wt in .ace/git/worktree.yml",
261
+ version: version
262
+ }
263
+ end
264
+ rescue
265
+ # Ignore parse failure
266
+ end
267
+ end
268
+ end
269
+
270
+ # Finding 6: ACE-development profile exception handling
271
+ if profile == "ace-development"
272
+ # Under ace-development profile, broad pipelines (batch worktrees, retrospectives, releases) are expected
273
+ findings << {
274
+ id: "rec-dev-pipeline",
275
+ severity: "info",
276
+ profile: "ace-development",
277
+ evidence: "ACE-development profile active",
278
+ resolved_source: "project",
279
+ current_value: "ace-development",
280
+ recommended_value: "ace-development",
281
+ rationale: "Broad development pipeline capabilities (batch, fork, release, demo) are enabled and accepted",
282
+ next_action: "No action required",
283
+ version: version
284
+ }
285
+ end
286
+
287
+ # Opt-in update check
288
+ if check_updates
289
+ findings << {
290
+ id: "rec-update-check",
291
+ severity: "info",
292
+ profile: profile,
293
+ evidence: "Opt-in update check feed evaluated",
294
+ resolved_source: "network",
295
+ current_value: "up_to_date",
296
+ recommended_value: "up_to_date",
297
+ rationale: "Ensures package recommendation versions match upstream releases",
298
+ next_action: "No update required",
299
+ version: version
300
+ }
301
+ end
302
+
303
+ # Apply acknowledgements: suppress acknowledged findings, re-expose expired ones
304
+ apply_acknowledgements(findings, profile: profile, root: root)
305
+
306
+ findings
307
+ end
308
+
92
309
  private
93
310
 
94
311
  def check_artifact_hygiene
@@ -1132,6 +1349,115 @@ module Ace
1132
1349
  token = token.delete_suffix("/")
1133
1350
  token
1134
1351
  end
1352
+
1353
+ # --- Acknowledgement support ---
1354
+
1355
+ # Load acknowledgements from project config and apply them to findings.
1356
+ # Active valid acknowledgements change severity to "acknowledged".
1357
+ # Expired acknowledgements keep original severity but attach prior context.
1358
+ def apply_acknowledgements(findings, profile:, root:)
1359
+ acks = load_acknowledgements(root)
1360
+ return if acks.nil? || acks.empty?
1361
+
1362
+ now = Time.now
1363
+
1364
+ findings.each do |finding|
1365
+ ack = acks[finding[:id]]
1366
+ next unless ack.is_a?(Hash)
1367
+
1368
+ validity = validate_acknowledgement(ack, finding, profile, now)
1369
+ case validity
1370
+ when :active
1371
+ finding[:original_severity] = finding[:severity]
1372
+ finding[:severity] = "acknowledged"
1373
+ finding[:acknowledgement] = ack
1374
+ when :expired
1375
+ finding[:acknowledgement] = ack
1376
+ finding[:acknowledgement_expired] = true
1377
+ end
1378
+ # :invalid — leave finding unchanged, ack is silently ignored
1379
+ end
1380
+ end
1381
+
1382
+ # Load recommendation_acknowledgements from project config file.
1383
+ # Returns a Hash keyed by finding id, or empty Hash.
1384
+ def load_acknowledgements(root)
1385
+ config_paths = [
1386
+ File.join(root, ".ace", "config", "config.yml"),
1387
+ File.join(root, ".ace", "config.yml")
1388
+ ]
1389
+ config_path = config_paths.find { |f| File.exist?(f) }
1390
+ return {} unless config_path
1391
+
1392
+ data = YAML.safe_load_file(config_path, permitted_classes: [Time, Date], aliases: true)
1393
+ data&.dig("recommendation_acknowledgements") || {}
1394
+ rescue StandardError
1395
+ {}
1396
+ end
1397
+
1398
+ # Validate an acknowledgement against finding, profile, and time.
1399
+ # Returns :active, :expired, or :invalid.
1400
+ def validate_acknowledgement(ack, finding, profile, now)
1401
+ # Required fields
1402
+ rationale = ack["rationale"].to_s.strip
1403
+ return :invalid if rationale.empty?
1404
+
1405
+ actor = ack["actor"].to_s.strip
1406
+ return :invalid if actor.empty?
1407
+
1408
+ acknowledged_at = parse_time(ack["acknowledged_at"])
1409
+ return :invalid unless acknowledged_at
1410
+
1411
+ # Profile scope: if declared, must match
1412
+ ack_profile = ack["profile"].to_s.strip
1413
+ return :invalid if !ack_profile.empty? && ack_profile != profile
1414
+
1415
+ # Version scope: if declared, must match finding version
1416
+ ack_version = ack["recommendation_version"].to_s.strip
1417
+ if !ack_version.empty? && finding[:version]
1418
+ return :invalid unless version_matches?(ack_version, finding[:version])
1419
+ end
1420
+
1421
+ # Time boundary: expires_at or recheck_after required
1422
+ expires_at = parse_time(ack["expires_at"])
1423
+ recheck_after = parse_time(ack["recheck_after"])
1424
+ return :invalid if expires_at.nil? && recheck_after.nil?
1425
+
1426
+ # Expiry check: at-the-instant is expired
1427
+ boundary = expires_at || recheck_after
1428
+ return :expired if now >= boundary
1429
+
1430
+ :active
1431
+ end
1432
+
1433
+ # Parse a time value from string or Time object.
1434
+ # Returns Time or nil.
1435
+ def parse_time(value)
1436
+ return nil if value.nil?
1437
+ return value if value.is_a?(Time)
1438
+ return value.to_time if value.is_a?(Date)
1439
+
1440
+ Time.parse(value.to_s)
1441
+ rescue ArgumentError, TypeError
1442
+ nil
1443
+ end
1444
+
1445
+ # Check if acknowledgement version constraint matches finding version.
1446
+ # Supports exact match and pessimistic (~>) constraints.
1447
+ def version_matches?(constraint, version)
1448
+ return true if constraint == version
1449
+
1450
+ if constraint.start_with?("~>")
1451
+ prefix = constraint.sub("~>", "").strip
1452
+ parts = prefix.split(".")
1453
+ return false if parts.length < 2
1454
+
1455
+ # Pessimistic: ~> 0.38 means >= 0.38.0 and < 1.0
1456
+ version.start_with?(parts[0...-1].join(".") + ".")
1457
+ else
1458
+ constraint == version
1459
+ end
1460
+ end
1135
1461
  end
1136
1462
  end
1137
1463
  end
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Support
5
5
  module Config
6
- VERSION = '0.17.1'
6
+ VERSION = '0.18.0'
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-support-config
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.1
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-30 00:00:00.000000000 Z
10
+ date: 2026-08-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-fs