ace-review 0.59.0 → 0.60.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: eeb8650e65f2751e795ea4db791eb9074f95b26ee0eb93d54e4db8040b826b47
4
- data.tar.gz: 58a8a7d0a1d268e7282029a9e7805b975deba5d56d6afc24d7b88ab7cf8e8650
3
+ metadata.gz: acf36b5361402d845cc259ac24c06b4dd1e686cbc25b0a80b62c34ae8453ab53
4
+ data.tar.gz: 1fbd838c775362c85876d5c160e5352ba3f048a502034659f3f3d14429ae43f3
5
5
  SHA512:
6
- metadata.gz: b0d0d7238c634039f0b40e3302bf7ba90ff6a84db3e4e6833e72632905590b15ff4df1c6e0da927633c15fa444e97e4fdaf2fbd7292adbe8fe9df3381f98a7f3
7
- data.tar.gz: d63ca8120a95fb200115fc27ece39f49ba4d3b662587ea94b5532f978c7a27bb8ed722746e46c0d6c46388f39f84494bb48c4eba0bc38d9053c0668a7acec7d8
6
+ metadata.gz: adc5171f507ca5b4adc1ada99d0688fb2878236a21339ef7d1fd6c5341e017966a3c721e09cb5bb33a528ebc239e261b137501e9db49fedea4ac9bb100b0cc6f
7
+ data.tar.gz: 2c7751781253a309563b56d204a981299429f02de6ce09313b19f6cd652e0c4d9f6d68ed83647460778ed6a3edc3e8d25e65995f09650964e754c5995aa251b4
@@ -10,7 +10,6 @@
10
10
  # max_concurrent_models - Max parallel LLM queries for multi-model execution (default: 3)
11
11
  # auto_execute - Auto-execute LLM query without confirmation (default: false)
12
12
  # provider_timeout - Timeout for forge operations in seconds (default: 30)
13
- # gh_simple_timeout - Timeout for simple gh commands like --version, auth status (default: 10)
14
13
  # llm_timeout - Timeout for LLM queries in seconds (default: 900)
15
14
  #
16
15
  # Environment Variables:
@@ -38,7 +37,6 @@ defaults:
38
37
  max_concurrent_models: 3 # Max parallel LLM queries for multi-model execution
39
38
  auto_execute: false # Set to true to auto-execute LLM query without confirmation
40
39
  provider_timeout: 30 # Timeout for forge operations in seconds
41
- gh_simple_timeout: 10 # Timeout for simple gh commands (--version, auth status)
42
40
  llm_timeout: 900 # Timeout for LLM queries in seconds (15 minutes)
43
41
 
44
42
  # Review-exempt paths
@@ -79,5 +77,13 @@ campaign:
79
77
  revision: delivery-v1
80
78
  minimum_rounds: 3
81
79
  clean_rounds: 2
80
+ maximum_rounds: 5
81
+ required_scopes: [full]
82
+ required_checks: [tests]
83
+ discovery:
84
+ revision: discovery-v1
85
+ minimum_rounds: 2
86
+ clean_rounds: 1
87
+ maximum_rounds: 2
82
88
  required_scopes: [full]
83
89
  required_checks: [tests]
data/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.60.1] - 2026-10-10
11
+
12
+ ### Fixed
13
+
14
+ - Require the published producer and consumer patch versions containing the protected context and managed-attempt APIs; exclude incompatible prior dependency graphs.
15
+
16
+ ## [0.60.0] - 2026-10-08
17
+
18
+ ### Added
19
+
20
+ - Enforce finite campaign phases, durable provider-attempt reservations, convergence limits, escalation and resume through the same campaign owner.
21
+ - Join canonical collection/check/approval child receipts and current parent result acceptance.
22
+
23
+ ### Fixed
24
+
25
+ - Accept forge-neutral URLs in typed PR subjects and use the verified held result snapshot without a second projection.
26
+
27
+ ### Added
28
+
29
+ - ReviewManager can execute a bounded prepared candidate through the maintained provider and feedback pipeline, requiring an explicit bound verdict and complete execution evidence before returning approval.
30
+
31
+ ### Fixed
32
+
33
+ - Remove the unused GitHub CLI timeout setting from the shipped defaults; forge operations use provider_timeout.
34
+ - Reject missing, duplicate-key or malformed feedback inventories instead of silently dropping findings and reporting a clean extraction.
35
+
36
+ ## [0.59.1] - 2026-10-06
37
+
38
+ ### Fixed
39
+
40
+ - Concurrent review starts exclusively claim isolated sessions, refuse occupied explicit paths, and preserve report and feedback provenance. Single-model execution reports its exact session path; feedback creation discovers its `review.md`. Release exports publish complete copies without replacing equal-clock reports.
41
+
10
42
  ## [0.59.0] - 2026-10-05
11
43
 
12
44
  ### Changed
data/docs/campaigns.md CHANGED
@@ -103,13 +103,14 @@ The final round may reference an explicit approval artifact with `approval: {"pa
103
103
  ```json
104
104
  {
105
105
  "producer":"IMPLEMENTER_ACTOR",
106
- "reviewer":"ACTUAL_EXECUTED_REVIEWER_MODEL",
106
+ "reviewer":"INDEPENDENT_REVIEWER_ACTOR",
107
107
  "verdict":"approved",
108
108
  "head":"EXACT_REVIEWED_HEAD_SHA",
109
109
  "base":"EXACT_REVIEWED_BASE_SHA",
110
110
  "contract_identity":"FROZEN_CONTRACT_SHA256",
111
111
  "required_scopes":["full"],
112
112
  "reports":[{"path":"SESSION/review-report-MODEL.md","sha256":"REPORT_SHA256"}],
113
+ "report_models":[{"report":{"path":"SESSION/review-report-MODEL.md","sha256":"REPORT_SHA256"},"report_model":"ACTUAL_EXECUTED_MODEL"}],
113
114
  "receipt":{"attempt_id":"ACCEPTED_REVIEW_APPROVAL_ATTEMPT_ID","digest":"ACCEPTED_RECEIPT_SHA256"},
114
115
  "checks":[{"name":"tests","verdict":"passed","receipt":{
115
116
  "attempt_id":"ACCEPTED_CHECK_ATTEMPT_ID","digest":"ACCEPTED_RECEIPT_SHA256"
@@ -119,7 +120,7 @@ The final round may reference an explicit approval artifact with `approval: {"pa
119
120
 
120
121
  The approval `receipt` refers to an ordinary managed `operation: "review"` receipt with an executed `review.verdict: "approved"`, the same exact head, producer/reviewer actors and checksummed report artifacts. Submit that attributable outcome through the existing coordinator after the independent reviewer actually approves. Do not include a campaign result in this source receipt; campaign consumption happens afterward. The read-only `attempt evidence --kind review-approval` boundary revalidates its accepted authority. A collection receipt certifies execution even when its report rejects the candidate; it cannot certify approval. Every retained approval remains authority-backed across head changes through explicit historical validation.
121
122
 
122
- Producer and reviewer must differ. Reviewer identity must match the actual completed execution model in the referenced session; approvals must cover every required scope. Required checks refer to a succeeded execution receipt already accepted by the ace-assign coordinator. The receipt operation must match the check: `tests` requires `test`; other explicit check names require the same operation name. An unrelated operation claiming a passed tests field is rejected. Run the check under an existing managed assignment attempt and submit its attributable result, current head, check outcomes and checksummed artifacts through `ace-assign attempt finish`. Use the accepted attempt ID and receipt digest here. A self-authored check JSON file is insufficient. Campaign recording and acceptance consult the read-only `ace-assign attempt evidence --attempt ID --receipt-digest DIGEST --format json --kind check --check-name tests` boundary, which revalidates current head and source artifacts against accepted history. Retain `ace-assign` alongside `ace-review` for this qjl evidence capability; unavailable authority blocks acceptance explicitly. Campaign commands never run the check for you or create a second execution journal. Authenticated producer/reviewer attribution and execution acceptance remain owned by ace-assign.
123
+ Producer and reviewer must differ. Reviewer identifies the authenticated actor, independently of the model. Each report must have exactly one `report_models` entry whose checksummed reference belongs to `reports` and whose `report_model` matches verified session execution metadata. Missing, duplicate, extra or mismatched entries fail; model names do not substitute for actor attribution. Approvals must cover every required scope. Required checks refer to a succeeded execution receipt already accepted by the ace-assign coordinator. The receipt operation must match the check: `tests` requires `test`; other explicit check names require the same operation name. An unrelated operation claiming a passed tests field is rejected. Run the check under an existing managed assignment attempt and submit its attributable result, current head, check outcomes and checksummed artifacts through `ace-assign attempt finish`. Use the accepted attempt ID and receipt digest here. A self-authored check JSON file is insufficient. Campaign recording and acceptance consult the read-only `ace-assign attempt evidence --attempt ID --receipt-digest DIGEST --format json --kind check --check-name tests` boundary, which revalidates current head and source artifacts against accepted history. Retain `ace-assign` alongside `ace-review` for this qjl evidence capability; unavailable authority blocks acceptance explicitly. Campaign commands never run the check for you or create a second execution journal. Authenticated producer/reviewer attribution and execution acceptance remain owned by ace-assign.
123
124
 
124
125
  ```sh
125
126
  ace-review campaign finish CAMPAIGN_ID --format json
@@ -139,6 +140,10 @@ Submit a normal succeeded review receipt through `ace-assign attempt finish`. Ad
139
140
 
140
141
  The receipt retains ordinary attempt/assignment/project/scope binding, producer, current head, reviewer approval and executed checks. Its producer/reviewer actors must match the campaign assessment. The trusted coordinator rechecks live campaign state and rejects stale, blocked, mismatched or dry-run results. Accepting evidence records a separate journal commit without moving the candidate branch.
141
142
 
143
+ ## Guarded source consumption
144
+
145
+ Source-owned receipt composition can call `CampaignManager#with_verified_result!(result:, subject:, contract_identity:, policy:, head:, base:, producer:, reviewer:) { |current| ... }`. It requires a block and an existing campaign lock, revalidates current acceptance and the exact expected tuple, and yields a deeply frozen projection while holding one read transaction. Keep the entire receipt/CAS operation inside that block; a retained projection is not a later permission. Do not call public campaign methods from inside the block. Exceptions release the lock, and validation failures never yield. This API does not itself provide protected child linkage or authorize a journal mutation.
146
+
142
147
  ## Restart, contract changes and failures
143
148
 
144
149
  Campaign authority lives in `.ace-local/review/campaigns/`; retain this directory and source evidence across process/context restarts. Records are serialized with a lock and replaced atomically with checksums. Every retained completed session, including earlier rounds and partial coverage, must keep its immutable accepted review-collection authority. Historical reads use `ace-assign attempt evidence --kind review-collection --historical-head EXACT_RECORDED_HEAD` to validate that receipt and its artifacts. This read-only mode cannot certify current checks; check-purpose proofs always require the live head. Losing/corrupting evidence from any retained counted round or assessment makes it unavailable; historical counters remain diagnostic and do not manufacture acceptance. These records are durable local artifacts, not a second assignment execution journal.
data/docs/usage.md CHANGED
@@ -65,7 +65,36 @@ ace-review [OPTIONS]
65
65
  | `--no-feedback` | Skip feedback extraction |
66
66
  | `--feedback-model` | Model for feedback extraction |
67
67
  | `--save-session` | Save session files (default: true) |
68
- | `--session-dir` | Custom session directory |
68
+ | `--session-dir` | Fresh session directory; existing empty directories may be claimed once |
69
+
70
+ ### Concurrent reviews and exact session selection
71
+
72
+ Each new review claims its own session before writing prompts or metadata. Automatic
73
+ sessions use `.ace-local/review/sessions/review-<id>` with an opaque suffix when a
74
+ name collides. Successful execution prints both the exported report and the actual
75
+ session directory. Same-clock reviews preserve separate reports and feedback.
76
+
77
+ ```bash
78
+ bin/ace-review --preset code-valid --subject diff:BASE..HEAD --auto-execute
79
+ # Output includes: Session directory: /project/.ace-local/review/sessions/review-<id>
80
+ bin/ace-review-feedback list --session /project/.ace-local/review/sessions/review-<id>
81
+ # Output contains only this session's findings.
82
+ ```
83
+
84
+ For manual feedback creation, pass the exact reported session path to
85
+ `bin/ace-review-feedback create --session PATH`. Single-model `review.md` and
86
+ multi-model reports are discovered within that directory. Published findings are
87
+ immutable; start a fresh review when findings already exist.
88
+
89
+ An explicit path must be absent or an unclaimed empty directory. A permanent claim
90
+ remains after success, failure or interruption. Reusing a claimed or nonempty path,
91
+ a regular file, or a symlink fails before reviewer execution and preserves existing
92
+ contents. Choose a fresh writable path after an allocation refusal:
93
+
94
+ ```bash
95
+ bin/ace-review --preset code-valid --subject diff:BASE..HEAD --session-dir .ace-local/review/source-round-2
96
+ # Output includes: Review session prepared: .ace-local/review/source-round-2
97
+ ```
69
98
 
70
99
  ### Informational
71
100
 
@@ -117,6 +146,14 @@ ace-review --list-prompts
117
146
 
118
147
  Manage feedback items extracted from reviews.
119
148
 
149
+ Extraction requires an explicit `findings` array in the synthesis response.
150
+ A missing array, duplicate JSON key, or finding without a nonempty textual title
151
+ and description fails the entire extraction; valid findings are not published
152
+ as a partial replacement. For example, `{"findings":[{"title":"","finding":"Bug"}]}`
153
+ is an extraction failure, while `{"findings":[]}` is a completed empty extraction.
154
+ Neither a provider's successful exit nor empty extraction is an approval verdict.
155
+
156
+
120
157
  ### Subcommands
121
158
 
122
159
  | Command | Description |
@@ -17,6 +17,17 @@ One round covers the PR's needed scope, including cross-module integration. A la
17
17
 
18
18
  Use at least one available reviewer per needed scope. Prefer the project's configured models, but preferences are not an allowlist. Honor the user's current provider/model restrictions. Record actual provider, model, reasoning and completion status. A zero process exit code alone is not a completed review.
19
19
 
20
+ ## Execution boundary
21
+
22
+ The ordinary commands below use the local campaign store and coordinator-accepted
23
+ ordinary receipts. For an installed protected parent with registered
24
+ `review_campaign`, use its original authenticated authority and the protected
25
+ handoff below instead. The installed launcher owns the original linked collection,
26
+ check and approval children; each must succeed, settle and release before its
27
+ receipt can count. A worker-local report or ordinary `attempt finish` cannot
28
+ replace that linkage. Draft creation/update may precede review; ready and merge
29
+ still require the accepted current-candidate result and executed checks.
30
+
20
31
  ## 1. Choose and prepare the scope
21
32
 
22
33
  Identify the PR from an explicit number, owner/repository#number or URL and read normalized evidence with `ace-git pr show IDENTIFIER --format json`. Use the assignment's retained forge selection: `--server NAME` or `--default-server`, mutually exclusive; otherwise resolve the repository remote. Keep that same selection on `ace-review --pr IDENTIFIER` in every round. Do not infer a forge or PR from a fixed hostname or caller identity. Read its goal, current requirements, relevant ADRs and changed-file list. Use the smallest set of presets covering the change. Small coherent PRs can be reviewed whole; split large inputs by high-level module or functional lens, not arbitrary file chunks. Include needed contracts and code when reviewing tests and include integration behavior when modules interact.
@@ -37,7 +48,7 @@ Verify reports against code and requirements. Use `ace-review-feedback list --se
37
48
 
38
49
  Fix confirmed P0/P1 and run appropriate tests. Verify P2 and lower findings, fix those within the task or defer with rationale; they do not independently require another round. Do not expand the PR to implement speculative improvements. If the same blocker recurs without an effective fix, diagnose the cause or request the needed product/architecture decision instead of repeating the same review.
39
50
 
40
- ## 3. Record progress and finish
51
+ ## 3. Ordinary campaign recording and finish
41
52
 
42
53
  Use the durable `ace-review campaign` authority for completed rounds, verified dispositions, required scopes and clean streak. Start/reuse it with explicit subject JSON and a frozen behavioral requirements document; snapshot the applicable delivery policy. Pin an empty round before collection with head/base, scope IDs and presets, then pass `--campaign ID --campaign-round ROUND --campaign-scope SCOPE` to the existing review runner. Have the existing assignment coordinator accept each completed collection as a `review-collect` receipt with a passed review-execution outcome and checksummed metadata/report/prompt artifacts. Include its accepted receipt reference with the session and verified feedback dispositions in `campaign record-round`; editable execution flags alone cannot count. Full PR scopes require the complete unfiltered whole-PR inventory without delta references. Filtered module reviews and delta reviews use explicit module or delta scopes frozen in the campaign policy; pin the delta reference before collection. The default delivery `full` policy requires whole-PR rounds. Record partial attempts without counting them as completed rounds. See `ace-review/docs/campaigns.md` for exact input schemas and examples.
43
54
 
@@ -52,3 +63,68 @@ Examples:
52
63
  - A round that finds a confirmed P1 is not clean even if it is fixed immediately afterward.
53
64
 
54
65
  After the completion rule is met and changes have appropriate test results, update the PR description with actual reviewers, scopes, fixes, deferred findings and limitations, then mark the draft ready. Finish with `ace-review campaign finish ID --format json` after the final round records independent current-head approval backed by an accepted ordinary review receipt and passing required check artifacts. The source approval receipt must bind the approved reviewer verdict, actors, head and report hashes; caller-written approval JSON and collection receipts alone cannot approve the candidate. Search convergence alone cannot approve stale/incomplete evidence or unresolved earlier P0/P1. The result remains a local artifact until ace-assign independently verifies it through its existing attempt receipt boundary. Keep formal merge checks at merge time. Do not merge without explicit user authorization, post replies to people or resolve their threads without authorization. Do not make optional code changes after completion that would unnecessarily reopen review.
66
+
67
+
68
+ ## Protected campaign recording and parent result handoff
69
+
70
+ Use the original parent mapping, assignment, attempt, exact candidate SHA and
71
+ positive candidate generation from its accepted registration/status. Campaign ID,
72
+ subject, frozen requirements identity and policy come from that registered parent;
73
+ these commands have no caller-selected campaign, project, scope or base flags.
74
+ The round's base and scopes remain the exact pinned R1 input. Do not run local
75
+ `ace-review campaign record-round` or `finish` as an installed authority substitute.
76
+
77
+ Upload the existing R1 round JSON inside the closed R2 envelope:
78
+ `{"version":1,"round":ROUND_OBJECT,"artifacts":[{"path":"report.md","sha256":"SHA256"}]}`.
79
+ `ROUND_OBJECT` retains the documented attempt_id, round_id, head/base,
80
+ required_scopes, scope_identity, sessions, dispositions and optional approval.
81
+ Its attempt_id equals the stable mutation ID below and its head equals the parent
82
+ SHA. Each artifact path is a bounded relative materialization name; it is not an
83
+ instruction for the authority to read the worker filesystem. Supply exact report,
84
+ metadata, prompt, feedback and approval bytes referenced by that round as ordered
85
+ `--artifact` files matching the envelope's paths/digests. The envelope is at most
86
+ 16 KiB, with at most 16 artifacts of at most 64 KiB each. The authority privately
87
+ materializes bytes and resolves child receipt references from canonical history;
88
+ collection proves execution, while independently accepted approval and matching
89
+ check children establish acceptance. Never add the parent campaign result to a
90
+ source child receipt.
91
+
92
+ ```sh
93
+ ace-assign campaign-record-round --mapping MAPPING --assignment PARENT --attempt ATTEMPT --head SHA --candidate-generation N --mutation ROUND-ATTEMPT --input ROUND-ENVELOPE.json --artifact REPORT-BYTES
94
+ ace-assign campaign-export-result --mapping MAPPING --assignment PARENT --attempt ATTEMPT --head SHA --candidate-generation N --output RESULT.json
95
+ ```
96
+
97
+ Repeat `--artifact` for all envelope artifacts in declared order, or omit it for
98
+ an empty pin. Record each completed round under the frozen policy; export succeeds
99
+ only when that policy, current candidate, independent approval and required checks
100
+ are accepted. Export creates mode0600 result bytes once and reports path, sha256,
101
+ result_identity and journal_commit. An existing output is reusable only when its
102
+ bytes match exactly; do not rewrite or compose a replacement result.
103
+
104
+ The original parent receipt keeps its existing assignment/attempt/project/scope,
105
+ producer, exact head, `operation: "review"`, `verdict: "succeeded"`, independent
106
+ reviewer approval and executed checks. Add
107
+ `"campaign":{"id":"REGISTERED-CAMPAIGN","result":{"path":"RESULT.json","sha256":"EXPORTED-SHA256"}}`
108
+ and include that identical reference in its `artifacts` array. Supply the exact
109
+ exported bytes in the matching ordered upload slot alongside the receipt's other
110
+ artifacts; local artifact names become canonical imported references on acceptance.
111
+ Submit through the maintained public boundary, using the persisted authority
112
+ generation and a stable original result mutation:
113
+
114
+ ```sh
115
+ ace-assign submit-result --mapping MAPPING --assignment PARENT --attempt ATTEMPT --head SHA --candidate-generation N --expected-generation G --mutation RESULT-MUTATION --receipt PARENT-RECEIPT.json --artifact RESULT.json
116
+ ```
117
+
118
+ This submits evidence; it does not independently approve, finish or release the
119
+ parent. The original reviewer/finish owner retains that gate. Authority generation
120
+ G and candidate generation N are distinct. Ready/merge revalidate this canonical
121
+ parent campaign result and checks at the current candidate.
122
+
123
+ On a lost reply/restart, preserve the exact round mutation, envelope and ordered
124
+ bytes; identical round replay returns retained history without another completed
125
+ round. Re-export may only reuse identical result bytes. Preserve original result
126
+ mutation, generation and upload bytes for result replay; do not refresh selectors
127
+ to manufacture success. Changed candidate, policy, requirements or missing child
128
+ settlement/source artifacts refuses current export/submission/ready/merge while
129
+ retaining historical rounds. Read original canonical status and route the precise
130
+ producer blocker; do not fabricate receipts or bypass to ordinary commands.
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "digest"
4
4
  require "json"
5
+ require "uri"
6
+ require "ace/git/atoms/server_url"
5
7
 
6
8
  module Ace
7
9
  module Review
@@ -86,15 +88,22 @@ module Ace
86
88
  parsed = Ace::Git::Atoms::PrIdentifier.parse(string!(pr, "pr"))
87
89
  raise Invalid, "PR must include repository identity (owner/repo#number)" unless parsed&.repo
88
90
  raise Invalid, "PR number must be positive" unless parsed.number.to_i.positive?
89
- repository = repository.delete_suffix("/")
90
- repo = parsed.repo
91
- if repository.start_with?("https://github.com/")
92
- unless repository.delete_prefix("https://github.com/").casecmp?(repo)
93
- raise Invalid, "PR repository identity does not match the declared repository"
94
- end
95
- repo = repo.downcase
96
- repository = "https://github.com/#{repo}"
91
+ begin
92
+ uri = URI.parse(repository)
93
+ rescue URI::InvalidURIError
94
+ raise Invalid, "PR repository must be an explicit HTTP(S) repository URL"
97
95
  end
96
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
97
+ raise Invalid, "PR repository must be an explicit HTTP(S) repository URL"
98
+ end
99
+ normalized = Ace::Git::Atoms::ServerUrl.normalize(repository)
100
+ repo = parsed.repo.downcase
101
+ # Retain the exact server/port and optional forge base path. The
102
+ # qualified PR names the final owner/repository pair on that server.
103
+ unless normalized.split("/").drop(1).last(2).join("/") == repo
104
+ raise Invalid, "PR repository identity does not match the declared repository"
105
+ end
106
+ repository = "#{uri.scheme.downcase}://#{normalized}"
98
107
  {"repository" => repository, "pr" => "#{repo}##{parsed.number.to_i}"}
99
108
  else
100
109
  {"repository" => repository, "local_candidate_id" => id!(local, "local_candidate_id")}
@@ -105,13 +114,16 @@ module Ace
105
114
  object!(value, "policy")
106
115
  required = %w[revision minimum_rounds clean_rounds required_scopes required_checks]
107
116
  raise Invalid, "policy requires #{required.join(', ')}" unless (required - value.keys).empty?
108
- raise Invalid, "unknown policy fields" unless (value.keys - required).empty?
117
+ raise Invalid, "unknown policy fields" unless (value.keys - required - ["maximum_rounds"]).empty?
109
118
  string!(value["revision"], "policy revision")
110
119
  %w[minimum_rounds clean_rounds].each do |key|
111
120
  raise Invalid, "#{key} must be a positive integer" unless value[key].is_a?(Integer) && value[key].positive?
112
121
  end
113
122
  strings!(value["required_scopes"], "required_scopes").each { |v| id!(v, "scope") }
114
123
  strings!(value["required_checks"], "required_checks")
124
+ if value.key?("maximum_rounds") && !(value["maximum_rounds"].is_a?(Integer) && value["maximum_rounds"].positive?)
125
+ raise Invalid, "maximum_rounds must be a positive integer"
126
+ end
115
127
  canonical(value)
116
128
  end
117
129
  end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+ require_relative "campaign_contract"
3
+
4
+ module Ace
5
+ module Review
6
+ module Atoms
7
+ # Exploration budgets are independent of the current-head acceptance gate.
8
+ module CampaignPolicy
9
+ Contract = CampaignContract
10
+ DEFAULTS = {
11
+ "discovery" => {"minimum_rounds" => 2, "clean_rounds" => 1, "maximum_rounds" => 2},
12
+ "delivery" => {"minimum_rounds" => 3, "clean_rounds" => 2, "maximum_rounds" => 5}
13
+ }.transform_values(&:freeze).freeze
14
+ TERMINAL_FAILURES = %w[authentication authorization model_unavailable unclassified].freeze
15
+ TRANSIENT_FAILURES = %w[timeout transport rate_limit unavailable].freeze
16
+ INFRASTRUCTURE_RETRIES = 2
17
+
18
+ def self.bounds!(profile:, policy:)
19
+ defaults = DEFAULTS[profile]
20
+ raise Contract::Invalid, "unsupported campaign profile #{profile}" unless defaults
21
+ Contract.object!(policy, "campaign policy")
22
+ result = defaults.merge(policy.slice("minimum_rounds", "clean_rounds", "maximum_rounds"))
23
+ unless result.values.all? { |value| value.is_a?(Integer) && value.positive? } &&
24
+ result["minimum_rounds"] <= result["maximum_rounds"] &&
25
+ result["clean_rounds"] <= result["maximum_rounds"] &&
26
+ result["maximum_rounds"] <= defaults["maximum_rounds"]
27
+ raise Contract::Invalid, "campaign policy conflicts with its finite execution budget"
28
+ end
29
+ if profile == "delivery" &&
30
+ (result["minimum_rounds"] < 3 || result["clean_rounds"] < 2)
31
+ raise Contract::Invalid, "delivery policy cannot weaken its search requirements"
32
+ end
33
+ result.freeze
34
+ end
35
+
36
+ def self.next_phase(record:, phase_id:, reason:, route:, additional_rounds:)
37
+ Contract.id!(phase_id, "phase ID")
38
+ Contract.string!(reason, "next phase reason")
39
+ Contract.string!(route, "next phase route")
40
+ unless additional_rounds.is_a?(Integer) && additional_rounds.positive?
41
+ raise Contract::Invalid, "next phase requires an explicit finite positive budget"
42
+ end
43
+ existing = record.fetch("phases").find { |phase| phase["id"] == phase_id }
44
+ if existing
45
+ unless existing.values_at("reason", "route", "maximum_rounds") == [reason, route, additional_rounds]
46
+ raise Contract::Invalid, "conflicting replay of authorized next phase"
47
+ end
48
+ return existing
49
+ end
50
+ {"id" => phase_id, "profile" => record.fetch("profile"), "round_start" => record.fetch("rounds").size,
51
+ "maximum_rounds" => additional_rounds, "reason" => reason, "route" => route}.freeze
52
+ end
53
+
54
+ def self.decision(profile:, bounds:, phase_rounds:, completed_rounds:, clean_streak:,
55
+ accepted:, recurrence: false, execution_failure: nil)
56
+ raise Contract::Invalid, "unsupported campaign profile #{profile}" unless DEFAULTS.key?(profile)
57
+ unless [phase_rounds, completed_rounds, clean_streak].all? { |n| n.is_a?(Integer) && n >= 0 } &&
58
+ phase_rounds <= completed_rounds && clean_streak <= completed_rounds && phase_rounds <= bounds.fetch("maximum_rounds") &&
59
+ [true, false].include?(accepted) && [true, false].include?(recurrence)
60
+ raise Contract::Invalid, "campaign counters or acceptance differ"
61
+ end
62
+ converged = profile == "delivery" && completed_rounds >= bounds.fetch("minimum_rounds") &&
63
+ clean_streak >= bounds.fetch("clean_rounds")
64
+ remaining = bounds.fetch("maximum_rounds") - phase_rounds
65
+ outcome, action = if profile == "discovery" && remaining.zero?
66
+ ["discovery_complete", "export_candidate"]
67
+ elsif execution_failure
68
+ ["execution_failed", "resolve_execution_failure"]
69
+ elsif profile == "delivery" && accepted && converged && !recurrence
70
+ ["accepted", "finish"]
71
+ elsif remaining.zero?
72
+ ["needs_escalation", "authorize_next_phase"]
73
+ elsif recurrence
74
+ ["needs_diagnosis", "diagnose_canonical_defect"]
75
+ elsif converged
76
+ ["search_converged", "satisfy_acceptance"]
77
+ else
78
+ ["in_progress", "review"]
79
+ end
80
+ {"profile" => profile, "outcome" => outcome, "next_action" => action,
81
+ "remaining_rounds" => remaining, "phase_rounds" => phase_rounds,
82
+ "search_converged" => converged, "accepted" => outcome == "accepted",
83
+ "execution_failure" => execution_failure}.freeze
84
+ end
85
+
86
+ # Severity corrections target the exact observation. A successful fix
87
+ # never retroactively changes the severity of the original round.
88
+ def self.clean_streak(record)
89
+ corrections = record.fetch("assessments").select { |item| item["kind"] == "severity_correction" }
90
+ .to_h { |item| [item.fetch("source_id"), item] }
91
+ rounds = record.fetch("rounds").to_h { |item| [item.fetch("attempt_id"), item] }
92
+ streak = 0
93
+ record.fetch("attempts").each do |attempt|
94
+ blocked = attempt.fetch("assessments").any? do |item|
95
+ correction = corrections[item.fetch("source_id")]
96
+ priority = correction ? correction.fetch("priority") : item.fetch("priority")
97
+ disposition = correction ? correction.fetch("disposition") : item.fetch("disposition")
98
+ item["observed_in_round"] && %w[high critical].include?(priority) && disposition != "invalid"
99
+ end
100
+ streak = 0 if blocked
101
+ if rounds.key?(attempt.fetch("attempt_id"))
102
+ round_observations = record.fetch("attempts").select { |item| attempt["round_id"] ? item["round_id"] == attempt["round_id"] : item["attempt_id"] == attempt["attempt_id"] }
103
+ .flat_map { |item| item.fetch("assessments") }
104
+ round_blocked = round_observations.any? do |item|
105
+ correction = corrections[item.fetch("source_id")]
106
+ priority = correction ? correction.fetch("priority") : item.fetch("priority")
107
+ disposition = correction ? correction.fetch("disposition") : item.fetch("disposition")
108
+ item["observed_in_round"] && %w[high critical].include?(priority) && disposition != "invalid"
109
+ end
110
+ streak = round_blocked ? 0 : streak + 1
111
+ end
112
+ end
113
+ streak
114
+ end
115
+
116
+ def self.canonical_findings(record)
117
+ findings = {}
118
+ (record.fetch("inherited_findings") + record.fetch("assessments")).each do |item|
119
+ next if item["kind"] == "repair_attempt"
120
+ id = item.fetch("id")
121
+ prior = findings[id]
122
+ sources = prior ? prior.fetch("sources") : item.fetch("sources", []).dup
123
+ source = item.slice("source_id", "artifact", "source_artifact", "priority", "disposition", "reason")
124
+ sources = sources + [source] unless sources.include?(source)
125
+ findings[id] = item.merge("sources" => sources)
126
+ end
127
+ findings.values
128
+ end
129
+
130
+ def self.recurring_blockers(record)
131
+ fixes = {}
132
+ recurring = {}
133
+ observed = []
134
+ record.fetch("assessments").each do |item|
135
+ id = item.fetch("id")
136
+ observation = item.slice("source_id", "artifact")
137
+ fresh_observation = !observed.include?(observation)
138
+ observed << observation if item["observed_in_round"] && fresh_observation
139
+ if item["kind"] == "repair_attempt"
140
+ fixes[id] = item
141
+ elsif %w[resolved invalid].include?(item["disposition"])
142
+ fixes.delete(id)
143
+ recurring.delete(id)
144
+ elsif item["observed_in_round"] && fresh_observation &&
145
+ %w[high critical].include?(item["priority"]) && fixes.key?(id)
146
+ recurring[id] = {"finding_id" => id, "repair_attempt" => fixes.fetch(id), "recurrence" => item}
147
+ end
148
+ end
149
+ recurring.values
150
+ end
151
+
152
+ # Entries are the durable before-call reservations, not report imports.
153
+ # An unanswered original call requires resolution, never a fresh retry.
154
+ def self.retry_decision(attempts:, round_id:, scope:, provider:)
155
+ [round_id, scope, provider].each { |value| Contract.string!(value, "execution selection") }
156
+ raise Contract::Invalid, "execution attempts must be an array" unless attempts.is_a?(Array)
157
+ attempts.each { |entry| Contract.object!(entry, "execution attempt") }
158
+ selected = attempts.select do |entry|
159
+ entry.values_at("round_id", "scope", "provider") == [round_id, scope, provider]
160
+ end
161
+ selected.each do |entry|
162
+ unless %w[running uncertain succeeded failed].include?(entry["status"]) &&
163
+ (entry["status"] != "failed" || (TERMINAL_FAILURES + TRANSIENT_FAILURES).include?(entry["failure"]))
164
+ raise Contract::Invalid, "execution attempt disposition differs"
165
+ end
166
+ end
167
+ state = if selected.any? { |entry| %w[running uncertain].include?(entry["status"]) }
168
+ "resolve_original_attempt"
169
+ elsif selected.any? { |entry| entry["status"] == "succeeded" }
170
+ "already_completed"
171
+ elsif selected.any? { |entry| TERMINAL_FAILURES.include?(entry["failure"]) }
172
+ "terminal_failure"
173
+ elsif selected.size >= INFRASTRUCTURE_RETRIES + 1
174
+ "retries_exhausted"
175
+ else
176
+ "execute"
177
+ end
178
+ {"next_action" => state, "execution_attempts" => selected.size,
179
+ "remaining_attempts" => [INFRASTRUCTURE_RETRIES + 1 - selected.size, 0].max}.freeze
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require_relative "campaign_policy"
2
3
 
3
4
  module Ace
4
5
  module Review
@@ -7,32 +8,28 @@ module Ace
7
8
  module CampaignProjection
8
9
  def self.build(record)
9
10
  rounds = record.fetch("rounds")
10
- findings = record.fetch("inherited_findings").to_h { |finding| [finding.fetch("id"), finding] }
11
- record.fetch("assessments").each { |finding| findings[finding.fetch("id")] = finding }
12
- open = findings.values.select { |finding| %w[open reopened].include?(finding["disposition"]) }
11
+ findings = CampaignPolicy.canonical_findings(record)
12
+ open = findings.select { |finding| %w[open reopened].include?(finding["disposition"]) }
13
13
  policy = record.fetch("policy")
14
14
  attempts = record.fetch("attempts")
15
- completed = rounds.to_h { |round| [round.fetch("attempt_id"), round] }
16
- streak = 0
17
- attempts.each do |attempt|
18
- # A verified blocker resets convergence when observed, even if its
19
- # logical round never completes or a different round completes next.
20
- if attempt.fetch("assessments").any? { |finding| finding["observed_in_round"] &&
21
- %w[high critical].include?(finding["priority"]) && finding["disposition"] != "invalid" }
22
- streak = 0
23
- end
24
- round = completed[attempt.fetch("attempt_id")]
25
- streak = round["clean"] ? streak + 1 : 0 if round
26
- end
15
+ streak = CampaignPolicy.clean_streak(record)
16
+ phase = record.fetch("phases").last
17
+ bounds = record.fetch("bounds").merge("maximum_rounds" => phase.fetch("maximum_rounds"))
18
+ decision = CampaignPolicy.decision(profile: record.fetch("profile"), bounds: bounds,
19
+ phase_rounds: rounds.size - phase.fetch("round_start"), completed_rounds: rounds.size,
20
+ clean_streak: streak, accepted: false, recurrence: !CampaignPolicy.recurring_blockers(record).empty?)
27
21
  sessions = attempts.flat_map { |attempt| attempt.fetch("sessions") }.uniq { |session| session["path"] }
28
22
  {"campaign_id" => record.fetch("id"), "subject" => record.fetch("subject"),
29
23
  "contract_identity" => record.fetch("contract_identity"), "effective_policy" => policy,
30
24
  "predecessor" => record["predecessor"], "successor_reason" => record["successor_reason"],
31
25
  "required_scopes" => policy.fetch("required_scopes"), "completed_rounds" => rounds.size,
32
- "clean_streak" => streak, "open_findings" => open, "findings" => findings.values,
33
- "search_converged" => rounds.size >= policy.fetch("minimum_rounds") &&
34
- streak >= policy.fetch("clean_rounds"),
35
- "counters" => {"recording_attempts" => attempts.size,
26
+ "clean_streak" => streak, "open_findings" => open, "findings" => findings,
27
+ "search_converged" => decision.fetch("search_converged"),
28
+ "profile" => record.fetch("profile"), "phase" => phase, "phases" => record.fetch("phases"),
29
+ "bounds" => record.fetch("bounds"), "decision" => decision,
30
+ "execution_attempts" => record.fetch("execution_attempts"),
31
+ "recurring_blockers" => CampaignPolicy.recurring_blockers(record),
32
+ "counters" => {"recording_attempts" => attempts.size, "execution_attempts" => record.fetch("execution_attempts").size,
36
33
  "provider_calls" => sessions.sum { |s| s.fetch("provider_calls") },
37
34
  "report_files" => sessions.sum { |s| s.fetch("report_files") }, "completed_rounds" => rounds.size},
38
35
  "rounds" => rounds, "attempts" => attempts, "head_transitions" => record.fetch("head_transitions")}
@@ -3,6 +3,8 @@ require_relative "commands/campaign/start"
3
3
  require_relative "commands/campaign/status"
4
4
  require_relative "commands/campaign/record_round"
5
5
  require_relative "commands/campaign/finish"
6
+ require_relative "commands/campaign/resume"
7
+ require_relative "commands/campaign/assess"
6
8
 
7
9
  module Ace
8
10
  module Review
@@ -11,7 +13,7 @@ module Ace
11
13
  extend Ace::Support::Cli::RegistryDsl
12
14
  PROGRAM_NAME = "ace-review campaign"
13
15
  REGISTERED_COMMANDS = [["start", "Start/reuse a frozen campaign"], ["status", "Inspect history and evidence"],
14
- ["record-round", "Pin or record scoped evidence"], ["finish", "Validate current acceptance"]].freeze
16
+ ["record-round", "Pin or record scoped evidence"], ["finish", "Validate current acceptance"], ["resume", "Authorize a finite next phase"], ["assess", "Record verified finding assessment"]].freeze
15
17
  HELP_EXAMPLES = ["ace-review campaign start --subject subject.json --contract requirements.md --profile delivery",
16
18
  "ace-review campaign status ID --format json", "ace-review campaign record-round ID --input round.json",
17
19
  "ace-review campaign finish ID --format json"].freeze
@@ -19,9 +21,11 @@ module Ace
19
21
  register "status", Commands::Campaign::Status
20
22
  register "record-round", Commands::Campaign::RecordRound
21
23
  register "finish", Commands::Campaign::Finish
24
+ register "resume", Commands::Campaign::Resume
25
+ register "assess", Commands::Campaign::Assess
22
26
  help = Ace::Support::Cli::HelpCommand.build(program_name: PROGRAM_NAME, version: Ace::Review::VERSION,
23
27
  commands: [["start", "Start/reuse a frozen campaign"], ["status", "Inspect history and evidence"],
24
- ["record-round", "Pin or record scoped evidence"], ["finish", "Validate current acceptance"]],
28
+ ["record-round", "Pin or record scoped evidence"], ["finish", "Validate current acceptance"], ["resume", "Authorize a finite next phase"], ["assess", "Record verified finding assessment"]],
25
29
  examples: ["ace-review campaign start --subject subject.json --contract requirements.md --profile delivery",
26
30
  "ace-review campaign status ID --format json", "ace-review campaign record-round ID --input round.json",
27
31
  "ace-review campaign finish ID --format json"])
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ require_relative "base"
3
+ module Ace
4
+ module Review
5
+ module CLI
6
+ module Commands
7
+ module Campaign
8
+ class Assess < Base
9
+ desc "Record verified severity correction or evidenced repair attempt"
10
+ argument :id, required: true, desc: "Durable campaign ID"
11
+ option :input, required: true, desc: "Assessment JSON with source identity, kind, reason and evidence"
12
+
13
+ def call(id:, **options)
14
+ run(options) { |manager| manager.assess_finding(id, read_json(options[:input]), dry_run: options[:dry_run]) }
15
+ 0
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end