ace-assign 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/config.yml +13 -0
  3. data/CHANGELOG.md +18 -0
  4. data/docs/exit-codes.md +10 -0
  5. data/docs/usage.md +32 -0
  6. data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
  7. data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
  9. data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
  10. data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
  11. data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
  12. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
  13. data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
  14. data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
  15. data/lib/ace/assign/cli/commands/status.rb +24 -1
  16. data/lib/ace/assign/cli.rb +30 -2
  17. data/lib/ace/assign/models/assignment.rb +21 -4
  18. data/lib/ace/assign/models/attempt.rb +178 -0
  19. data/lib/ace/assign/models/attempt_binding.rb +116 -0
  20. data/lib/ace/assign/models/evidence_event.rb +82 -0
  21. data/lib/ace/assign/models/execution_receipt.rb +135 -0
  22. data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
  23. data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
  24. data/lib/ace/assign/molecules/attempt_store.rb +201 -0
  25. data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
  26. data/lib/ace/assign/molecules/evidence_journal.rb +424 -0
  27. data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
  28. data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
  29. data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
  30. data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -0
  31. data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
  32. data/lib/ace/assign/version.rb +1 -1
  33. data/lib/ace/assign.rb +56 -0
  34. metadata +20 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ae38e39385e77c124bb67fc25b8ae8f1f5eef5e79d4779e8b40f1047bae52b6
4
- data.tar.gz: 323a3390bf57c7543443ba3c49b1d67483436cff03c5cfcebcb2aec2c8f99bc0
3
+ metadata.gz: c66fd1fc72e1bb186537931c960477dc7f9405ce3bc0f41c195a4b6601aa9bd1
4
+ data.tar.gz: fef52c4c68bbb1435499d7cb0468d09919b5b330d9d7226b4c676c54c12ff9ad
5
5
  SHA512:
6
- metadata.gz: '02824e0e864c5694a606c11e10c68259bbc4d31741e934dfe89c99ef643f8a501d7126beba2fc85230732653e930d82c9ec2cdbb58662e077df6b4e3a0b0e88c'
7
- data.tar.gz: 5e602ed50ddf6f34bfc5ee73a1808a507901d36f9d469063759482c9334ff1a2f08494c3243d997ce2deda876671b6353619ea9494881dd6da62cf18ec7722c0
6
+ metadata.gz: 8e31f4379b900c7906be0b15b92768c904d57bf3fcfccbaf284ca972168413be443907281e1f1b939dff66337daf2835f32fc27c27fc802738dbe19238f220c4
7
+ data.tar.gz: f77a7e04992fddf9b8ddf99e57243759e1ec8ccbd7502fbae9836e32bd749a60b5feb7d7c4a63017f92b384efdce49654547b7ecb1d18fb29c66cab644dad18a
@@ -28,6 +28,19 @@ execution:
28
28
  timeout: 1800
29
29
  launch_mode: auto # auto | headless | tmux
30
30
 
31
+ # Attempt evidence persistence (managed attempts)
32
+ attempt:
33
+ # Dedicated Git ref holding accepted execution evidence, outside the
34
+ # deliverable candidate branch; journal paths are execution/<assignment-id>/
35
+ evidence_git_ref: "refs/ace/execution"
36
+ # Disposable isolated audit checkout root (relative to project root)
37
+ evidence_checkout_root: ".ace-local/assign/evidence-checkout"
38
+ # Trusted execution-boundary identity adapter: local (interactive
39
+ # coordinator) or service (identity supplied by a trusted service executor
40
+ # via service_identity_env). Unknown identity fails closed.
41
+ identity_adapter: "local"
42
+ service_identity_env: "ACE_ASSIGN_SERVICE_IDENTITY"
43
+
31
44
  # Subtree execution behavior
32
45
  subtree:
33
46
  pre_commit_review: true
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.58.0] - 2026-09-28
11
+
12
+ ### Added
13
+ - Durable assignment attempts: `ace-assign attempt start|status|finish|reconcile` bind an immutable attempt ID to an assignment step/subtree, project, boundary-derived actor identity, and `base_head`, with a `reserved -> running -> succeeded/failed/stopped/uncertain` lifecycle and conservative reconciliation of interruptions (no automatic replay of merge/publish/deploy).
14
+ - Managed attempts journal accepted execution evidence to the configured evidence Git ref (default `refs/ace/execution`) through an isolated audit checkout with flock serialization and expected-old-value `git update-ref` compare-and-swap; `refs/ace/execution` never advances the deliverable candidate branch.
15
+ - Structured execution receipts: verifiable artifact digests, executed checks, executed independent reviewer verdicts for the exact current head, and rejection of credentials/terminal output in accepted evidence.
16
+ - `ace-assign status` (JSON) now includes the active attempt, `base_head`, `candidate_head`, `evidence_git_ref`, `journal_commit`, and unresolved effects.
17
+
18
+ ### Changed
19
+ - BREAKING: delivery evidence is derived exclusively from accepted, current-head journal receipts. The previous interpretation that a current `.ace-local/review`/`.ace-local/release` report plus hardcoded `terminal` feedback authorizes merge is removed; `merge_decision` is now `authorized` or `approval-required` (never `report-only`). Existing `EvidenceCalculator` consumers must read evidence from attempts.
20
+ - BREAKING: taskless assignments persist attempts locally (`recovery_mode: local_only`), never claim Git-backed recovery, and cannot record external effects until attached to a source task.
21
+ - New `attempt` error family exits with code 5 (conflict, rejected receipt, unauthorized identity, unavailable evidence, illegal attempt state).
22
+
23
+ ## [0.57.1] - 2026-09-27
24
+
25
+ ### Technical
26
+ - Stop depending on the removed review-fit preset step in tests.
27
+
10
28
  ## [0.57.0] - 2026-09-24
11
29
 
12
30
  ### Changed
data/docs/exit-codes.md CHANGED
@@ -20,6 +20,7 @@ This document describes the exit codes returned by the `ace-assign` CLI.
20
20
  | 2 | Assignment error | No active assignment or assignment not found |
21
21
  | 3 | Configuration not found | Config file does not exist |
22
22
  | 4 | Step not found | Referencing a step that does not exist |
23
+ | 5 | Attempt error | Attempt conflict, rejected receipt, unauthorized identity, unavailable evidence, or illegal attempt state |
23
24
 
24
25
  ## Exit Code Details
25
26
 
@@ -55,6 +56,15 @@ The requested step reference does not exist in the target assignment:
55
56
  - `retry` with unknown step number
56
57
  - Commands that target a specific missing step
57
58
 
59
+ ### Exit Code 5: Attempt Error
60
+ An attempt, receipt, identity, or evidence problem rejected the operation:
61
+ - `attempt start` with a conflicting binding for an owned subtree
62
+ - `attempt finish`/`attempt reconcile` with a receipt that fails verification (wrong binding, stale head, digest mismatch, self-approval, forbidden fields)
63
+ - Worker-identity attempts to accept a succeeded verdict
64
+ - Unknown or missing execution-boundary identity
65
+ - Reconciling a terminal attempt, or resolving uncertainty without a receipt
66
+ - Unavailable or unwritable evidence storage blocking a managed effect
67
+
58
68
  ## See Also
59
69
 
60
70
  - [Usage Guide](usage.md) for full command reference
data/docs/usage.md CHANGED
@@ -85,6 +85,38 @@ ace-assign add --step review-pr --after 100 --child
85
85
  ace-assign add --yaml .ace-local/assign/jobs/add-task.yml --after 010 --child
86
86
  ```
87
87
 
88
+ ## Attempt Evidence
89
+
90
+ Execution evidence lives in assignment attempts, not in reports. An attempt binds an immutable ID to an assignment, step/subtree scope, project, boundary-derived actor/role/runtime, source task, and `base_head`; the deliverable `candidate_head` is pinned only when accepted evidence is submitted, and the evidence journal commit is tracked separately from both.
91
+
92
+ ### Attempt lifecycle
93
+
94
+ ```bash
95
+ ace-assign attempt start --assignment ASSIGNMENT --step STEP --project PROJECT
96
+ ace-assign attempt status --assignment ASSIGNMENT --format json
97
+ ace-assign attempt finish --attempt ATTEMPT --receipt receipt.json
98
+ ace-assign attempt reconcile --attempt ATTEMPT --receipt receipt.json
99
+ ```
100
+
101
+ - `attempt start` returns a JSON projection of the attempt (ID, state, binding facts, evidence references, digests). A repeated identical start returns the same attempt; a conflicting start exits 5 without launching a second writer.
102
+ - `attempt status` exposes only state, binding, references, and digests -- never credentials or receipt bytes.
103
+ - `attempt finish` accepts a structured receipt: operation, attributed producer, exact tested/reviewed head, verdict, artifact paths with SHA-256 digests, executed checks, and -- for review operations -- an executed independent reviewer verdict for that head.
104
+ - `attempt reconcile` classifies interrupted attempts (`stopped` before process start, `uncertain` when an effect cannot be proven either way, still `running` only for verifiably live processes) and resolves uncertainty only against a verified receipt attributed to the recorded execution boundary. Merge, publish, and deploy effects are never replayed automatically.
105
+
106
+ ### State machine
107
+
108
+ `reserved -> running -> succeeded | failed | stopped | uncertain`; reconciliation may resolve `uncertain -> succeeded | failed`. Terminal attempts accept no new effects, and accepted history is append-only.
109
+
110
+ ### Evidence storage and recovery modes
111
+
112
+ - Task-attached (managed) attempts journal accepted evidence to the configured evidence Git ref (default `refs/ace/execution`) through an isolated, disposable audit checkout, outside the deliverable candidate branch. Journal commits never advance or exempt the reviewed candidate.
113
+ - Taskless assignments persist attempts under `.ace-local/assign` with `recovery_mode: local_only`. This path never claims Git-backed recovery and cannot record external effects (`merge`, `publish`, `deploy`, `release`); attach the assignment to a source task before managed delivery.
114
+ - Actor identity always comes from the execution boundary (OS login for local use, or the trusted service executor's identity). Unknown identity fails closed; flags never grant identity or authority.
115
+
116
+ ### status integration
117
+
118
+ `ace-assign status --format json` includes the active attempt, `base_head`, `candidate_head`, `evidence_git_ref`, `journal_commit`, and unresolved effects. Feedback state is derived from accepted current-head evidence: `terminal` (accepted evidence at the current head), `open` (attempts without a terminal outcome), `uncertain` (unresolved interruptions), or `unknown` (no attempts). Merge authorization requires an executed independent review verdict for the current head plus an independent release receipt when relevant; report files, exit codes, and prose never authorize a merge.
119
+
88
120
  ## Commands
89
121
 
90
122
  ### `ace-assign create`
@@ -657,21 +657,71 @@ Follow the instructions directly, performing the described work:
657
657
 
658
658
  ### 4. External Action Rule (Attempt-First)
659
659
 
660
- For external-facing steps (for example PR/review/release/push/update lifecycle steps):
660
+ Every external-facing step (implement, verify, review, merge, publish, deploy, release) runs inside a scoped attempt. The attempt -- not the report and not exit status -- is the delivery record.
661
661
 
662
- - Attempt the step command(s) first.
663
- - If blocked, capture concrete evidence:
662
+ **Step 1: Start the attempt before executing the step.** Actor identity is derived from the execution boundary; never pass actor flags.
664
663
 
665
- - command attempted
666
- - exact error output
667
- - why the step cannot proceed
664
+ ```bash
665
+ ATTEMPT_JSON=$(ace-assign attempt start --assignment "$ASSIGNMENT_ID" --step "$STEP_NUMBER" --project "$PROJECT_ID")
666
+ echo "$ATTEMPT_JSON"
667
+ ```
668
+
669
+ - A repeated identical start returns the same attempt; a conflicting start fails (exit 5). Never launch a second writer for an owned subtree.
670
+ - `base_head` is captured exactly once at start. Record the `attempt_id`.
671
+
672
+ **Step 2: Execute the step command(s).** If blocked, capture concrete evidence:
673
+
674
+ - command attempted
675
+ - exact error output
676
+ - why the step cannot proceed
668
677
 
669
- - Mark step failed with evidence (do not report synthetic completion).
678
+ Mark the step failed with evidence (do not report synthetic completion):
670
679
 
671
680
  ```bash
672
681
  ace-assign fail --message "Command failed: <cmd>. Error: <exact stderr>" --assignment "$ASSIGNMENT_TARGET"
673
682
  ```
674
683
 
684
+ **Step 3: Submit a structured receipt -- never prose -- to close the attempt.**
685
+
686
+ ```bash
687
+ cat > /tmp/attempt-receipt.json << 'EOF'
688
+ {
689
+ "attempt_id": "<ATTEMPT_ID>",
690
+ "assignment_id": "<ASSIGNMENT_ID>",
691
+ "project_id": "<PROJECT_ID>",
692
+ "scope": "<STEP_NUMBER>",
693
+ "operation": "implement",
694
+ "producer": {"actor": "<boundary-actor>", "role": "worker", "runtime": "<boundary-runtime>"},
695
+ "head": "<exact tested/reviewed HEAD sha>",
696
+ "verdict": "succeeded",
697
+ "artifacts": [{"path": "report.md", "sha256": "<sha256>"}],
698
+ "checks": [{"name": "ace-test", "verdict": "passed"}],
699
+ "review": {"reviewer": {"actor": "<independent-reviewer>", "runtime": "<reviewer-runtime>"}, "verdict": "approved", "head": "<same HEAD sha>"}
700
+ }
701
+ EOF
702
+
703
+ ace-assign attempt finish --attempt "$ATTEMPT_ID" --receipt /tmp/attempt-receipt.json
704
+ ```
705
+
706
+ Receipt rules enforced by the coordinator:
707
+
708
+ - `succeeded` verdicts require verifiable artifact digests, passing checks, and trusted coordinator/service authority; worker runtimes may submit attributable results but cannot self-approve.
709
+ - Review approval requires an executed independent reviewer verdict for the exact current head; same author/reviewer and report-only existence are rejected.
710
+ - Receipts carrying credentials, environment dumps, or terminal output are rejected outright.
711
+ - External effects (`merge`, `publish`, `deploy`, `release`) additionally require an accepted independent review receipt for the current head. Taskless assignments cannot record external effects.
712
+
713
+ **Step 4: Handle interruptions by classifying and reconciling -- never by assuming.**
714
+
715
+ ```bash
716
+ ace-assign attempt status --assignment "$ASSIGNMENT_ID" --format json
717
+ ace-assign attempt reconcile --attempt "$ATTEMPT_ID"
718
+ ace-assign attempt reconcile --attempt "$ATTEMPT_ID" --receipt /tmp/attempt-receipt.json
719
+ ```
720
+
721
+ - A lost receipt after a possible effect is `uncertain`; it resolves only against a verified receipt attributed to the recorded execution boundary.
722
+ - Running attempts stay running only while their recorded process is verifiably live; anything else becomes `stopped` or `uncertain`.
723
+ - Never replay `merge`, `publish`, or `deploy` automatically. Reconciliation records evidence; it never re-executes effects.
724
+
675
725
  ### Human-in-the-Loop (HITL) Stall Protocol
676
726
 
677
727
  Canonical source of truth: `wfi://hitl`.
@@ -717,7 +767,7 @@ For a blocked step:
717
767
 
718
768
  ### 5. Write Report (Only After Real Execution)
719
769
 
720
- After completing the step work, write a brief report summarizing what was accomplished:
770
+ After the attempt accepted a receipt (or the step was failed with evidence), write a brief report summarizing what was accomplished. The report advances the queue; the accepted attempt receipt carries the delivery evidence:
721
771
 
722
772
  ```bash
723
773
  # Write report content to a temp file
@@ -737,6 +787,12 @@ EOF
737
787
  ace-assign finish --message /tmp/step-report.md --assignment "$ASSIGNMENT_TARGET"
738
788
  ```
739
789
 
790
+ Prohibited:
791
+
792
+ - Advancing a step when its attempt has no accepted receipt (or recorded failure evidence).
793
+ - Treating a written report, exit 0, or prose as proof of completion. Check `ace-assign attempt status --assignment "$ASSIGNMENT_ID" --format json` for accepted evidence and unresolved effects.
794
+ - Reporting `uncertain` attempts as complete; reconcile them first.
795
+
740
796
  ### 6. Verify State Transition (Required)
741
797
 
742
798
  After each `ace-assign finish --message` or `ace-assign fail`, verify queue state:
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Atoms
6
+ # Pure validation and canonicalization of attempt scopes.
7
+ #
8
+ # An attempt scope is the step/subtree an attempt owns, expressed as a
9
+ # step number ("010", "010.01"). Scopes are canonicalized before they
10
+ # key ownership locks, journal paths, or binding comparisons so that
11
+ # cosmetic differences cannot create a second writer.
12
+ module AssignmentScope
13
+ SCOPE_PATTERN = /\A\d+(?:\.\d+)*\z/.freeze
14
+
15
+ # Canonicalize and validate a step/subtree scope.
16
+ #
17
+ # @param step [String] Step or subtree root number
18
+ # @return [String] Canonical scope
19
+ # @raise [Ace::Assign::AttemptErrors::InvalidScope] if blank or malformed
20
+ def self.canonicalize(step)
21
+ scope = step.to_s.strip
22
+ if scope.empty? || !scope.match?(SCOPE_PATTERN)
23
+ raise Ace::Assign::AttemptErrors::InvalidScope,
24
+ "Invalid attempt scope '#{step}': expected a step number such as 010 or 010.01"
25
+ end
26
+
27
+ scope
28
+ end
29
+
30
+ # Filesystem-safe token for an assignment/scope pair, used to key
31
+ # active-attempt ownership records.
32
+ #
33
+ # @param assignment_id [String] Assignment ID
34
+ # @param scope [String] Canonical scope
35
+ # @return [String] Ownership key token
36
+ def self.ownership_key(assignment_id, scope)
37
+ "#{assignment_id}-#{canonicalize(scope)}.json"
38
+ end
39
+
40
+ # Compare two scopes for binding identity.
41
+ #
42
+ # @param left [String] First scope
43
+ # @param right [String] Second scope
44
+ # @return [Boolean] True if both canonicalize to the same scope
45
+ def self.equal?(left, right)
46
+ canonicalize(left) == canonicalize(right)
47
+ rescue Ace::Assign::AttemptErrors::InvalidScope
48
+ false
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Atoms
6
+ # Pure state machine for assignment attempt lifecycles.
7
+ #
8
+ # Legal transitions:
9
+ # - reserved -> running
10
+ # - running -> succeeded | failed | stopped | uncertain
11
+ # - uncertain -> succeeded | failed (only via reconciliation)
12
+ #
13
+ # Terminal states are succeeded, failed, and stopped. Terminal attempts
14
+ # never accept new transitions, effects, or receipts; accepted history is
15
+ # immutable. A stop is never success, and an uncertain attempt must be
16
+ # resolved through explicit reconciliation, never automatic replay.
17
+ module AttemptStateMachine
18
+ STATES = %w[reserved running succeeded failed stopped uncertain].freeze
19
+ TERMINAL_STATES = %w[succeeded failed stopped].freeze
20
+ RESOLVED_STATES = %w[succeeded failed].freeze
21
+
22
+ TRANSITIONS = {
23
+ "reserved" => %w[running].freeze,
24
+ "running" => %w[succeeded failed stopped uncertain].freeze,
25
+ "uncertain" => RESOLVED_STATES.freeze
26
+ }.freeze
27
+
28
+ # @return [Array<String>] All known attempt states
29
+ def self.states
30
+ STATES
31
+ end
32
+
33
+ # @param state [String] State to inspect
34
+ # @return [Boolean] True if the state is terminal (no further transitions)
35
+ def self.terminal?(state)
36
+ TERMINAL_STATES.include?(state.to_s)
37
+ end
38
+
39
+ # @param state [String] State to inspect
40
+ # @return [Boolean] True if the state awaits reconciliation
41
+ def self.uncertain?(state)
42
+ state.to_s == "uncertain"
43
+ end
44
+
45
+ # Check whether a transition is legal without raising.
46
+ #
47
+ # @param from [String] Current state
48
+ # @param to [String] Requested next state
49
+ # @return [Boolean] True if the transition is legal
50
+ def self.can_transition?(from, to)
51
+ TRANSITIONS.fetch(from.to_s, []).include?(to.to_s)
52
+ end
53
+
54
+ # Return the legal next states for a state.
55
+ #
56
+ # @param from [String] Current state
57
+ # @return [Array<String>] Legal next states (empty for unknown/terminal)
58
+ def self.next_states(from)
59
+ TRANSITIONS.fetch(from.to_s, [])
60
+ end
61
+
62
+ # Validate a transition.
63
+ #
64
+ # @param from [String] Current state
65
+ # @param to [String] Requested next state
66
+ # @raise [Ace::Assign::AttemptErrors::InvalidTransition] if illegal
67
+ def self.transition!(from, to)
68
+ return to.to_s if can_transition?(from, to)
69
+
70
+ raise Ace::Assign::AttemptErrors::InvalidTransition,
71
+ "Illegal attempt transition #{from} -> #{to} (legal from #{from}: #{next_states(from).join(', ')})"
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Atoms
9
+ # Pure canonical digest helpers for attempt evidence.
10
+ #
11
+ # Every accepted evidence record (events, receipts, artifacts) carries a
12
+ # SHA-256 digest over a canonical JSON serialization (sorted keys, no
13
+ # insignificant whitespace) so records remain comparable across writers
14
+ # and reloads. Digests are over non-secret metadata only.
15
+ module EvidenceDigest
16
+ # Canonical JSON serialization of an object.
17
+ #
18
+ # Sorts hash keys recursively and strips insignificant whitespace so
19
+ # semantically equal payloads produce byte-identical output.
20
+ #
21
+ # @param object [Object] JSON-serializable object
22
+ # @return [String] Canonical JSON string
23
+ def self.canonical_json(object)
24
+ JSON.generate(sort_keys(object))
25
+ end
26
+
27
+ # SHA-256 hex digest over the canonical JSON of an object.
28
+ #
29
+ # @param object [Object] JSON-serializable object
30
+ # @return [String] 64-character hex digest
31
+ def self.digest(object)
32
+ digest_string(canonical_json(object))
33
+ end
34
+
35
+ # SHA-256 hex digest over raw bytes.
36
+ #
37
+ # @param bytes [String] Raw byte content
38
+ # @return [String] 64-character hex digest
39
+ def self.digest_string(bytes)
40
+ Digest::SHA256.hexdigest(bytes)
41
+ end
42
+
43
+ # SHA-256 hex digest of a file's contents.
44
+ #
45
+ # @param path [String] File path
46
+ # @return [String] 64-character hex digest
47
+ # @raise [Errno::ENOENT] if the file does not exist
48
+ def self.digest_file(path)
49
+ digest_string(File.read(path))
50
+ end
51
+
52
+ # Short digest prefix for file naming (collision-resistant enough for
53
+ # append-only event filenames; full digests remain in the payload).
54
+ #
55
+ # @param object [Object] JSON-serializable object
56
+ # @return [String] 12-character hex prefix
57
+ def self.short_digest(object)
58
+ digest(object)[0, 12]
59
+ end
60
+
61
+ class << self
62
+ private
63
+
64
+ def sort_keys(value)
65
+ case value
66
+ when Hash
67
+ value.sort.to_h.transform_values { |v| sort_keys(v) }
68
+ when Array
69
+ value.map { |item| sort_keys(item) }
70
+ else
71
+ value
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Assign
7
+ module CLI
8
+ module Commands
9
+ # Public `ace-assign attempt` command namespace (interface contract:
10
+ # start, status, finish, reconcile).
11
+ module Attempt
12
+ # Shared coordinator construction, option validation, and JSON output.
13
+ module Base
14
+ private
15
+
16
+ def build_coordinator
17
+ Organisms::AttemptCoordinator.new
18
+ end
19
+
20
+ def require_option(options, key, usage)
21
+ value = options[key].to_s.strip
22
+ raise Ace::Support::Cli::Error, "Missing --#{key}: usage: ace-assign attempt #{usage}" if value.empty?
23
+
24
+ value
25
+ end
26
+
27
+ def emit_json(payload)
28
+ puts JSON.generate(payload)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base"
5
+
6
+ module Ace
7
+ module Assign
8
+ module CLI
9
+ module Commands
10
+ module Attempt
11
+ # Accept a structured execution receipt and record the outcome.
12
+ # Terminal and uncertain attempts refuse new receipts.
13
+ class Finish < Ace::Support::Cli::Command
14
+ include Ace::Support::Cli::Base
15
+ include Attempt::Base
16
+
17
+ desc "Finish an attempt with a structured execution receipt"
18
+
19
+ option :attempt, desc: "Attempt ID"
20
+ option :receipt, desc: "Path to the receipt JSON file"
21
+
22
+ def call(**options)
23
+ attempt_id = require_option(options, :attempt, "finish --attempt ID --receipt FILE")
24
+ receipt = require_option(options, :receipt, "finish --attempt ID --receipt FILE")
25
+
26
+ result = build_coordinator.finish(attempt_id: attempt_id, receipt_path: receipt)
27
+
28
+ emit_json(result.projection)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base"
5
+
6
+ module Ace
7
+ module Assign
8
+ module CLI
9
+ module Commands
10
+ module Attempt
11
+ # Classify interruptions and resolve uncertainty against verified,
12
+ # boundary-attributed receipts. Never replays external effects.
13
+ class Reconcile < Ace::Support::Cli::Command
14
+ include Ace::Support::Cli::Base
15
+ include Attempt::Base
16
+
17
+ desc "Reconcile an interrupted or uncertain attempt"
18
+
19
+ option :attempt, desc: "Attempt ID"
20
+ option :receipt, desc: "Path to the receipt JSON file (required to resolve uncertainty)"
21
+
22
+ def call(**options)
23
+ attempt_id = require_option(options, :attempt, "reconcile --attempt ID [--receipt FILE]")
24
+ receipt = options[:receipt].to_s.strip
25
+
26
+ result = build_coordinator.reconcile(
27
+ attempt_id: attempt_id,
28
+ receipt_path: receipt.empty? ? nil : receipt
29
+ )
30
+
31
+ emit_json(result.projection)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base"
5
+
6
+ module Ace
7
+ module Assign
8
+ module CLI
9
+ module Commands
10
+ module Attempt
11
+ # Bind an immutable attempt to an assignment step/subtree.
12
+ #
13
+ # Identity is derived from the execution boundary; a repeated
14
+ # identical start returns the active attempt, and a conflicting
15
+ # binding fails without launching a second writer.
16
+ class Start < Ace::Support::Cli::Command
17
+ include Ace::Support::Cli::Base
18
+ include Attempt::Base
19
+
20
+ desc "Start a scoped attempt for an assignment step"
21
+
22
+ option :assignment, desc: "Assignment ID"
23
+ option :step, desc: "Step or subtree scope (e.g. 010 or 010.01)"
24
+ option :project, desc: "Project ID the attempt executes in"
25
+
26
+ def call(**options)
27
+ usage = "start --assignment ID --step STEP --project ID"
28
+ assignment = require_option(options, :assignment, usage)
29
+ step = require_option(options, :step, usage)
30
+ project = require_option(options, :project, usage)
31
+
32
+ attempt = build_coordinator.start(
33
+ assignment_id: assignment,
34
+ step: step,
35
+ project_id: project
36
+ )
37
+
38
+ emit_json(attempt.projection)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base"
5
+
6
+ module Ace
7
+ module Assign
8
+ module CLI
9
+ module Commands
10
+ module Attempt
11
+ # Show attempt state, binding facts, and evidence references.
12
+ # JSON output carries only state, binding, references, and digests
13
+ # — never credentials or receipt bytes.
14
+ class Status < Ace::Support::Cli::Command
15
+ include Ace::Support::Cli::Base
16
+ include Attempt::Base
17
+
18
+ desc "Show attempt status for an assignment"
19
+
20
+ option :assignment, desc: "Assignment ID"
21
+ option :format, desc: "Output format (json, text)", default: "json"
22
+
23
+ def call(**options)
24
+ assignment = require_option(options, :assignment, "status --assignment ID --format json")
25
+ projection = build_coordinator.status(assignment)
26
+
27
+ if options[:format] == "text"
28
+ print_text(assignment, projection)
29
+ else
30
+ emit_json(projection || {"attempt" => nil})
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def print_text(assignment, projection)
37
+ return puts "No attempts recorded for #{assignment}" if projection.nil?
38
+
39
+ puts "Attempt: #{projection['attempt_id']} #{projection['state']} (#{projection['scope']})"
40
+ puts "Recovery: #{projection['recovery_mode']}"
41
+ puts "Base head: #{projection['base_head']}"
42
+ puts "Candidate head: #{projection['candidate_head'] || 'unpinned'}"
43
+ puts "Evidence ref: #{projection['evidence_git_ref'] || 'local-only'}"
44
+ puts "Journal commit: #{projection['journal_commit'] || 'none'}"
45
+ return unless projection["unresolved_effects"].any?
46
+
47
+ puts "Unresolved effects: #{projection['unresolved_effects'].join(', ')}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end