ace-review 0.58.0 → 0.58.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fda3bb688af1e2d4c71e6f37dc20d25c4f756703c3a2910827629975fd0965e1
4
- data.tar.gz: a479d205b501a785063680654466e0eda84e5d00397cc74831dbffc24d106552
3
+ metadata.gz: b74a181828465000d62fedc0062c118681144724a8beec940af5a335dade03b7
4
+ data.tar.gz: 700918d82fb6f27833a1b0f054301912d1a99dd5609b6140d93ff155a272f0b5
5
5
  SHA512:
6
- metadata.gz: b5d61e56ef9b7c8cd02a8c44130e4c0d43266d3327290e963136f36b442a596d6070b9736d94f46427265be4696bc43c4d0e8c69142eaa4fd5019a0e6db31b9c
7
- data.tar.gz: 0dde4d996c5f713b5f5babfe57f03814cfe658d217207071da16f8248e387e14bad4916184e8175c0f603842efac319636a73d5e1394b6b92a7c29dea072b208
6
+ metadata.gz: 3747d044dc834fe44d7977dfa120052709e5db45885a0c4fa39f7bbb60d4b819e6ea4c0f1f70c7189229b580f565b8936e6d5c1821ca04d4eb787a4cc84add61
7
+ data.tar.gz: 8ac3e30edd97356132095db4deca911ec62db8ba4c8ab0b92f7de8a11a8b6464cf5a9404180d8420d6eb18badb398ec158ee9e6f1b60a37f117d0dfbd4f6d6b6
@@ -60,6 +60,7 @@ instructions:
60
60
  bundle: "project"
61
61
 
62
62
  models:
63
- - role:review-claude
64
- - role:review-codex
65
- - role:review-gemini
63
+ # One reviewer position with fallback chain (roles in ace-llm config):
64
+ # pi glm 5.3 -> codex sol -> agy flash 3.8; the round is good if even
65
+ # one returns. astra runs once per PR via role:review-astra.
66
+ - role:review-default
@@ -40,5 +40,4 @@ instructions:
40
40
  bundle: "project"
41
41
 
42
42
  models:
43
- - role:review-claude
44
- - role:review-codex
43
+ - role:review-default
@@ -35,6 +35,7 @@ instructions:
35
35
  bundle: "project"
36
36
 
37
37
  models:
38
- - role:review-claude
39
- - role:review-codex
40
- - role:review-gemini
38
+ # One reviewer position with fallback chain (roles in ace-llm config):
39
+ # pi glm 5.3 -> codex sol -> agy flash 3.8; the round is good if even
40
+ # one returns. astra runs once per PR via role:review-astra.
41
+ - role:review-default
data/CHANGELOG.md CHANGED
@@ -7,15 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.58.1] - 2026-10-02
11
+
12
+ ### Fixed
13
+ - Campaign finish scopes availability re-hashing to the newest round's evidence and the content-addressed finding snapshots; earlier rounds validate through journal-backed historical authority reads, so resolving findings no longer permanently blocks finish.
14
+
15
+
10
16
  ## [0.58.0] - 2026-10-02
11
17
 
12
18
  ### Added
13
19
  - Provider-neutral PR reviews and comment handling for GitHub, default Forgejo, and named Forgejo servers with exact-head evidence.
14
20
 
15
21
  ### Changed
22
+ - Review model queries honor the configured ace-llm fallback (role candidate
23
+ chains + `llm.fallback`) instead of forcing single-leg execution, so a
24
+ reviewer position survives a dead provider leg.
16
25
  - Replaced `--gh-timeout` with `--provider-timeout`; removed the GitHub-only review fetcher, poster, and resolver paths.
17
26
  - Preserve provider and repository identity across review sessions and comments; fail closed on moved heads and collection errors.
18
27
 
28
+ ## [0.57.1] - 2026-10-02
29
+
30
+ ### Fixed
31
+
32
+ - A failed or incomplete provider entry no longer invalidates a campaign session's completed reviewer executions; one completed reviewer per needed scope still completes the round.
33
+
19
34
 
20
35
  ## [0.57.0] - 2026-10-01
21
36
 
@@ -42,18 +42,19 @@ module Ace
42
42
  end
43
43
  review_threads = comments_data[:review_threads] || []
44
44
 
45
- # Build report sections
46
- frontmatter = build_frontmatter(comments_data)
47
- summary = build_summary(comments, reviews, review_threads, pr_number, pr_title)
48
45
  # Apply the same bot filter to thread comments; threads left
49
- # without human comments are omitted from feedback.
46
+ # without human comments are omitted from feedback (filtered
47
+ # before the summary so bot-only threads never inflate counts).
50
48
  review_threads = review_threads.map do |thread|
51
49
  filtered = thread.merge(comments: Array(thread[:comments]).reject { |c| c[:author].to_s.match?(/\b(bot|github-actions|ace-review)\b/i) })
52
50
  filtered.merge(comments: filtered[:comments])
53
51
  end.reject { |thread| Array(thread[:comments]).empty? }
52
+ # Build report sections
53
+ frontmatter = build_frontmatter(comments_data)
54
+ summary = build_summary(comments, reviews, review_threads, pr_number, pr_title)
54
55
  inline_section = build_inline_comments_section(review_threads)
55
56
  unresolved_section = build_unresolved_section(comments, reviews)
56
- resolved_section = build_resolved_section(reviews)
57
+ resolved_section = build_resolved_section(reviews, current_head: comments_data[:head_sha].to_s)
57
58
  comments_table = build_comments_table(comments, reviews)
58
59
 
59
60
  # Combine sections
@@ -190,13 +191,26 @@ module Ace
190
191
  # Build resolved feedback section
191
192
  #
192
193
  # @param reviews [Array<Hash>] Code reviews
194
+ # @param current_head [String] Reviewed PR head (empty when unknown)
193
195
  # @return [String, nil] Resolved section or nil if empty
194
- def self.build_resolved_section(reviews)
196
+ def self.build_resolved_section(reviews, current_head: nil)
195
197
  resolved = []
198
+ current = current_head.to_s
196
199
 
197
- # Add approvals
200
+ # Add approvals. Approvals attach to a reviewed commit; only those
201
+ # matching the current head are current approvals, earlier-head
202
+ # approvals are labeled, and unknown-head approvals are called out
203
+ # as unknown instead of presenting as current.
198
204
  reviews.select { |r| r[:state] == "APPROVED" }.each do |review|
199
- resolved << "@#{review[:author]} approved changes"
205
+ review_head = review[:head_sha].to_s
206
+ label = if review_head.empty?
207
+ " (head unknown)"
208
+ elsif review_head == current
209
+ ""
210
+ else
211
+ " (for earlier head #{review_head[0, 12]})"
212
+ end
213
+ resolved << "@#{review[:author]} approved changes#{label}"
200
214
  end
201
215
 
202
216
  return nil if resolved.empty?
@@ -18,6 +18,16 @@ module Ace
18
18
  class CampaignEvidence
19
19
  Contract = Atoms::CampaignContract
20
20
 
21
+ # One completion predicate for provider executions, shared with the
22
+ # extraction-inventory writer so session metadata and campaign
23
+ # evidence can never disagree about which reports were reviewed.
24
+ def self.completed_entry?(entry)
25
+ execution = entry["execution"] || {}
26
+ entry["status"] == "success" && execution["status"].to_s == "succeeded" &&
27
+ !execution["provider"].to_s.empty? && !execution["model"].to_s.empty? &&
28
+ !entry["completed_at"].to_s.empty?
29
+ end
30
+
21
31
  def initialize(repo_root:, check_evidence: nil, review_evidence: nil, approval_evidence: nil)
22
32
  @repo_root = File.realpath(repo_root)
23
33
  authority = CampaignExecutionEvidence.new(repo_root: @repo_root)
@@ -57,9 +67,9 @@ module Ace
57
67
  metadata = json_keys(YAML.safe_load_file(metadata_path, permitted_classes: [Time, Date, Symbol]))
58
68
  Contract.object!(metadata, "session metadata")
59
69
  expected = {"campaign_id" => record["id"], "contract_identity" => record["contract_identity"],
60
- "subject" => record["subject"], "round_id" => binding["round_id"], "scope" => scope,
61
- "head" => binding["head"], "base" => binding["base"],
62
- "scope_identity" => binding["scope_identity"][scope]}
70
+ "subject" => record["subject"], "round_id" => binding["round_id"], "scope" => scope,
71
+ "head" => binding["head"], "base" => binding["base"],
72
+ "scope_identity" => binding["scope_identity"][scope]}
63
73
  unless metadata["campaign_binding"] == expected && metadata["preset"] == expected["scope_identity"]["preset"]
64
74
  raise Contract::Invalid, "session campaign/contract/scope/head/base identity mismatch"
65
75
  end
@@ -83,12 +93,7 @@ module Ace
83
93
  reports = []
84
94
  entries.each do |entry|
85
95
  Contract.object!(entry, "model execution")
86
- execution = entry["execution"] || {}
87
- complete = entry["status"] == "success" && execution["status"] == "succeeded" &&
88
- !execution["provider"].to_s.empty? && !execution["model"].to_s.empty? &&
89
- !entry["completed_at"].to_s.empty?
90
- completed &&= complete
91
- next unless complete
96
+ next unless self.class.completed_entry?(entry)
92
97
  path = entry["output_file"].to_s
93
98
  raise Contract::Invalid, "completed execution has no report" if path.empty?
94
99
  path = File.expand_path(path, dir)
@@ -99,7 +104,7 @@ module Ace
99
104
  resolved, report_ref = artifact("path" => path, "sha256" => entry["report_sha256"])
100
105
  raise Contract::Invalid, "empty reviewer report" if File.read(resolved).strip.empty?
101
106
  refs << report_ref
102
- reports << {"artifact" => report_ref, "execution" => execution}
107
+ reports << {"artifact" => report_ref, "execution" => entry["execution"] || {}}
103
108
  prompts = entry["prompt_sha256"]
104
109
  Contract.object!(prompts, "execution prompt checksums")
105
110
  %w[system user].each do |name|
@@ -117,6 +122,10 @@ module Ace
117
122
  raise Contract::Invalid, "feedback extraction inventory or reviewed reports changed"
118
123
  end
119
124
  end
125
+ # One completed reviewer per needed scope suffices: a failed or
126
+ # incomplete provider entry does not count as a reviewer, but it
127
+ # does not invalidate the model executions that did complete.
128
+ completed &&= reports.any?
120
129
  completed &&= extracted
121
130
  proof = completed ? @review_evidence.call(input.fetch("receipt"), head: binding["head"], artifacts: refs) : nil
122
131
  {"receipt" => input["receipt"], "execution_proof" => proof, "path" => dir.delete_prefix(@repo_root + File::SEPARATOR), "scope" => scope,
@@ -113,8 +113,11 @@ module Ace
113
113
  output: output_file,
114
114
  format: "text",
115
115
  timeout: timeout || Ace::Review.get("defaults", "llm_timeout") || 300,
116
- force: true,
117
- fallback: false # Disable ace-llm fallback - ace-review handles retries
116
+ force: true
117
+ # Fallback is config-driven (llm.fallback + role candidate
118
+ # chains): the reviewer position tries pi glm 5.3, then codex
119
+ # sol, then agy flash 3.8 — the round is good if even one
120
+ # returns, and the session records the winner only.
118
121
  )
119
122
 
120
123
  unless result[:text].is_a?(String) && !result[:text].strip.empty?
@@ -141,8 +141,11 @@ module Ace
141
141
  comments: [{id: comment.id, author: comment.author, body: comment.body, url: comment.url}]}
142
142
  end
143
143
  reviews = snapshot.review_evidence.reviews.map do |review|
144
+ # Carry the reviewed commit so the formatter can tell
145
+ # current-head approvals from approvals of earlier heads.
144
146
  {id: review.id, author: review.author, body: review.body,
145
- state: review.state.to_s.upcase, url: review.url}
147
+ state: review.state.to_s.upcase, url: review.url,
148
+ head_sha: review.head_sha}
146
149
  end
147
150
  {
148
151
  success: true, comments: issue_comments, review_threads: threads,
@@ -310,7 +310,16 @@ module Ace
310
310
  revision_error = e.message
311
311
  end
312
312
  round = record["rounds"].last
313
- refs = record["attempts"].flat_map do |attempt|
313
+ # Availability re-hashing covers the current round's evidence plus
314
+ # every finding snapshot. Earlier rounds' sessions and approvals are
315
+ # validated by journal-backed historical authority reads instead:
316
+ # their working files legitimately age (feedback resolve archives
317
+ # and annotates finding files; test report `latest` paths move on),
318
+ # so hashing them here would permanently block finish for any
319
+ # campaign that resolved findings. Finding snapshots are
320
+ # content-addressed and must remain available.
321
+ current_attempt_ids = round ? [round["attempt_id"]] : []
322
+ refs = record["attempts"].select { |attempt| current_attempt_ids.include?(attempt["attempt_id"]) }.flat_map do |attempt|
314
323
  attempt["sessions"].flat_map { |session| session["artifacts"] } +
315
324
  (attempt["approval"] ? [attempt["approval"]["artifact"]] + attempt["approval"]["artifacts"] : [])
316
325
  end + (record["assessments"] + record["inherited_findings"]).map { |finding| finding["artifact"] }
@@ -376,6 +376,18 @@ module Ace
376
376
  delta = Molecules::DeltaResolver.resolve(options.delta, metadata, project_root: @project_root || Dir.pwd)
377
377
  return {success: false, error: delta[:error]} unless delta[:success]
378
378
 
379
+ # The snapshot above was read before resolution ran; re-read the
380
+ # provider metadata and compare identity so a head, base, or
381
+ # repository move during delta computation fails the round instead
382
+ # of certifying a delta against stale identity.
383
+ recheck_result = pr_provider(options).fetch_metadata(pr_identifier)
384
+ return {success: false, error: recheck_result[:error]} unless recheck_result[:success]
385
+ rechecked = recheck_result[:metadata]
386
+ identity_keys = %w[server_name provider repository_url number headRefOid baseRefOid]
387
+ if identity_keys.any? { |key| rechecked[key] != metadata[key] }
388
+ return {success: false, error: "PR identity changed during delta resolution; delta round rejected"}
389
+ end
390
+
379
391
  options.pr_metadata = metadata
380
392
  # Carry the reference session's findings forward as evidence; an explicit
381
393
  # --evidence-session still merges on top.
@@ -413,7 +425,8 @@ module Ace
413
425
  pr_metadata: metadata, delta: delta,
414
426
  diff_manifest: manifest.merge(scoped[:manifest]).merge(
415
427
  exempt_files: classification[:exempt],
416
- exempt_patterns: Array(config[:exempt_paths]))}
428
+ exempt_patterns: Array(config[:exempt_paths])
429
+ )}
417
430
  end
418
431
 
419
432
  scoped = apply_exempt_paths(scoped, config)
@@ -494,10 +507,10 @@ module Ace
494
507
  lines << ""
495
508
  lines << "# No-op review round"
496
509
  lines << ""
497
- if content[:noop_reason] == :empty_delta && delta
498
- lines << "Scope: delta since `#{delta[:reference_head]}` — the delta is empty; there is no change to review."
510
+ lines << if content[:noop_reason] == :empty_delta && delta
511
+ "Scope: delta since `#{delta[:reference_head]}` — the delta is empty; there is no change to review."
499
512
  else
500
- lines << "Scope: #{content[:noop_scope] || "no-op"}."
513
+ "Scope: #{content[:noop_scope] || "no-op"}."
501
514
  end
502
515
  lines << ""
503
516
  lines << "Verdict: no new findings. This round is clean only if no carried-forward Critical/High finding remains unresolved."
@@ -1395,13 +1408,21 @@ module Ace
1395
1408
  # Read review content
1396
1409
  review_content = File.read(review_file)
1397
1410
 
1411
+ # The identity digest covers only stable review content: the
1412
+ # metadata header carries a fresh timestamp on every run, so hashing
1413
+ # the whole file would mint a new identity for identical review
1414
+ # text and let an uncertain-post retry post a duplicate. The
1415
+ # dry-run preview uses the same digest so preview and post agree.
1416
+ stable_content = review_content.sub(/\A---\n.*?\n---\n\n/m, "")
1417
+ review_digest = Digest::SHA256.hexdigest(stable_content)
1418
+
1398
1419
  # Dry-run prepares the exact comment body without posting; the
1399
1420
  # response builder reads :preview. The timestamp derives from the
1400
1421
  # review digest — identical to what a real post would send.
1401
1422
  if options.dry_run
1402
1423
  preview = Molecules::PrProvider.format_comment(
1403
1424
  review_content, preset: review_data[:preset], model: review_data[:model],
1404
- timestamp: "review-#{Digest::SHA256.file(review_file).hexdigest[0, 12]}"
1425
+ timestamp: "review-#{review_digest[0, 12]}"
1405
1426
  )
1406
1427
  return {success: true, dry_run: true, preview: preview}
1407
1428
  end
@@ -1416,7 +1437,6 @@ module Ace
1416
1437
  # keyed by everything reuse depends on: PR identity, head, and
1417
1438
  # the review artifact digest. Identical review text for another
1418
1439
  # PR therefore never collides.
1419
- review_digest = Digest::SHA256.file(review_file).hexdigest
1420
1440
  # The numeric PR (not the raw reference spelling) keys the
1421
1441
  # persisted identity, matching the provider-side marker basis.
1422
1442
  identity_pr = (options.pr_metadata&.fetch("number", nil) || options.pr).to_s
@@ -1429,6 +1449,10 @@ module Ace
1429
1449
  # any new timestamp is generated.
1430
1450
  identity_preset = review_data[:preset].to_s
1431
1451
  identity_model = review_data[:model].to_s
1452
+ # The identity includes the review artifact digest: a retry of
1453
+ # the same artifact reuses the persisted marker (no duplicate),
1454
+ # while a regenerated review for a later round is a new artifact
1455
+ # and gets its own comment.
1432
1456
  identity_key = Digest::SHA256.hexdigest(
1433
1457
  [identity_server, identity_repo, identity_pr, identity_head, review_digest,
1434
1458
  identity_preset, identity_model].join("\0")
@@ -1454,10 +1478,13 @@ module Ace
1454
1478
  else
1455
1479
  # The marker IS the identity key: deterministic for the same
1456
1480
  # identity even when the local record was lost, so the posted
1457
- # body and correlation always reconcile.
1481
+ # body and correlation always reconcile. The body is formatted
1482
+ # from the same stable content the digest hashes: embedding the
1483
+ # timestamped frontmatter would make a rerun with identical
1484
+ # review text report a content conflict after a lost record.
1458
1485
  session_key = identity_key
1459
1486
  content = Molecules::PrProvider.format_comment(
1460
- review_content, preset: review_data[:preset], model: review_data[:model],
1487
+ stable_content, preset: review_data[:preset], model: review_data[:model],
1461
1488
  # Deterministic per identity: the review digest stands in
1462
1489
  # for the mtime so re-formatting after a lost record still
1463
1490
  # reconciles.
@@ -1496,8 +1523,12 @@ module Ace
1496
1523
  reviewed_server = options.pr_metadata&.fetch("server_name", nil)
1497
1524
  reviewed_repo = options.pr_metadata&.fetch("repository_url", nil)
1498
1525
  reviewed_number = options.pr_metadata&.fetch("number", nil)
1526
+ # Normalize both sides: forgejo strips .git from its PR URL while
1527
+ # comment evidence retains the configured URL, so exact comparison
1528
+ # would fail a successful post.
1529
+ normalize = ->(url) { url.to_s.chomp("/").sub(/\.git\z/i, "") }
1499
1530
  if (reviewed_server && receipt.comment.server_name != reviewed_server) ||
1500
- (reviewed_repo && receipt.comment.repository_url != reviewed_repo) ||
1531
+ (reviewed_repo && normalize.call(receipt.comment.repository_url) != normalize.call(reviewed_repo)) ||
1501
1532
  (reviewed_number && receipt.comment.pr_number != reviewed_number)
1502
1533
  raise Ace::Git::ProviderIdentityMismatchError,
1503
1534
  "Posted comment identity (#{receipt.comment.server_name}/#{receipt.comment.repository_url}##{receipt.comment.pr_number}) does not match the reviewed PR"
@@ -1757,7 +1788,17 @@ module Ace
1757
1788
  # completes; an absent directory cannot attest a zero-findings review.
1758
1789
  def save_campaign_feedback_metadata(session_dir, result)
1759
1790
  path = File.join(session_dir, "metadata.yml")
1760
- metadata = YAML.safe_load_file(path, permitted_classes: [Time, Date, Symbol])
1791
+ # Session metadata is symbol-keyed YAML; normalize before any key
1792
+ # reads so the completed-execution rule and the binding check see
1793
+ # string keys, matching the campaign evidence reader.
1794
+ normalize = lambda do |value|
1795
+ case value
1796
+ when Hash then value.to_h { |k, v| [k.to_s, normalize.call(v)] }
1797
+ when Array then value.map { |v| normalize.call(v) }
1798
+ else value
1799
+ end
1800
+ end
1801
+ metadata = normalize.call(YAML.safe_load_file(path, permitted_classes: [Time, Date, Symbol]))
1761
1802
  return unless metadata["campaign_binding"]
1762
1803
  extraction = {"status" => result ? "failed" : "skipped"}
1763
1804
  if result && result[:success]
@@ -1770,9 +1811,13 @@ module Ace
1770
1811
  unless result[:items_count] == ids.size && ids.uniq.size == ids.size
1771
1812
  raise Errors::BundleProcessingError, "feedback extraction inventory is incomplete"
1772
1813
  end
1773
- entries = metadata["models"] || [YAML.safe_load_file(File.join(session_dir, "llm_metadata.yml"),
1774
- permitted_classes: [Time, Date, Symbol])]
1775
- digests = entries.map { |entry| entry["report_sha256"] }.compact
1814
+ entries = metadata["models"] || [normalize.call(YAML.safe_load_file(
1815
+ File.join(session_dir, "llm_metadata.yml"), permitted_classes: [Time, Date, Symbol]
1816
+ ))]
1817
+ # Same completed rule as the campaign evidence reader: a failed
1818
+ # provider that left partial output is not a reviewed report.
1819
+ digests = entries.select { |entry| Molecules::CampaignEvidence.completed_entry?(entry) }
1820
+ .map { |entry| entry["report_sha256"] }.compact
1776
1821
  extraction = {"status" => "succeeded", "finding_ids" => ids, "report_sha256" => digests}
1777
1822
  end
1778
1823
  metadata["feedback_extraction"] = extraction
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Review
5
- VERSION = "0.58.0"
5
+ VERSION = "0.58.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-review
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.58.0
4
+ version: 0.58.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-02 00:00:00.000000000 Z
10
+ date: 2026-10-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli