ace-coworker 0.6.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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/coworker/config.yml +6 -0
  3. data/.ace-defaults/coworker/roles/integrator.yml +15 -0
  4. data/.ace-defaults/coworker/roles/reviewer.yml +15 -0
  5. data/.ace-defaults/coworker/roles/spec.yml +14 -0
  6. data/.ace-defaults/coworker/roles/worker.yml +13 -0
  7. data/.ace-defaults/coworker/sequences/integrator.yml +20 -0
  8. data/.ace-defaults/coworker/sequences/reviewer.yml +20 -0
  9. data/.ace-defaults/coworker/sequences/spec.yml +16 -0
  10. data/.ace-defaults/coworker/sequences/worker.yml +21 -0
  11. data/.ace-defaults/nav/protocols/wfi-sources/ace-coworker.yml +19 -0
  12. data/CHANGELOG.md +255 -0
  13. data/README.md +98 -0
  14. data/Rakefile +13 -0
  15. data/docs/usage.md +564 -0
  16. data/exe/ace-coworker +16 -0
  17. data/handbook/workflow-instructions/coworker/integrator.wf.md +162 -0
  18. data/handbook/workflow-instructions/coworker/reviewer.wf.md +211 -0
  19. data/handbook/workflow-instructions/coworker/spec.wf.md +100 -0
  20. data/handbook/workflow-instructions/coworker/worker.wf.md +103 -0
  21. data/lib/ace/coworker/atoms/active_executor_rule.rb +71 -0
  22. data/lib/ace/coworker/atoms/brief_formatter.rb +33 -0
  23. data/lib/ace/coworker/atoms/config_schema.rb +121 -0
  24. data/lib/ace/coworker/atoms/integration_gate.rb +92 -0
  25. data/lib/ace/coworker/atoms/outcome_planner.rb +79 -0
  26. data/lib/ace/coworker/atoms/prepare_decision.rb +75 -0
  27. data/lib/ace/coworker/atoms/prompt_template.rb +28 -0
  28. data/lib/ace/coworker/atoms/report_record_formatter.rb +60 -0
  29. data/lib/ace/coworker/atoms/resume_brief_formatter.rb +84 -0
  30. data/lib/ace/coworker/atoms/resume_planner.rb +78 -0
  31. data/lib/ace/coworker/atoms/role_preset_schema.rb +91 -0
  32. data/lib/ace/coworker/atoms/round_record_schema.rb +62 -0
  33. data/lib/ace/coworker/atoms/round_rule.rb +105 -0
  34. data/lib/ace/coworker/cli/commands/commission.rb +100 -0
  35. data/lib/ace/coworker/cli/commands/gate.rb +91 -0
  36. data/lib/ace/coworker/cli/commands/prepare.rb +138 -0
  37. data/lib/ace/coworker/cli/commands/report.rb +79 -0
  38. data/lib/ace/coworker/cli/commands/resume.rb +57 -0
  39. data/lib/ace/coworker/cli/commands/roles.rb +60 -0
  40. data/lib/ace/coworker/cli/commands/rounds.rb +99 -0
  41. data/lib/ace/coworker/cli/commands/start.rb +39 -0
  42. data/lib/ace/coworker/cli/commands/status.rb +95 -0
  43. data/lib/ace/coworker/cli.rb +83 -0
  44. data/lib/ace/coworker/errors.rb +10 -0
  45. data/lib/ace/coworker/models/assignment_snapshot.rb +65 -0
  46. data/lib/ace/coworker/models/brief.rb +38 -0
  47. data/lib/ace/coworker/models/config.rb +54 -0
  48. data/lib/ace/coworker/models/outcome.rb +39 -0
  49. data/lib/ace/coworker/models/preparation.rb +67 -0
  50. data/lib/ace/coworker/models/resume_plan.rb +47 -0
  51. data/lib/ace/coworker/molecules/assignment_metadata.rb +57 -0
  52. data/lib/ace/coworker/molecules/assignment_probe.rb +57 -0
  53. data/lib/ace/coworker/molecules/config_loader.rb +119 -0
  54. data/lib/ace/coworker/molecules/git_state_probe.rb +63 -0
  55. data/lib/ace/coworker/molecules/hitl_reporter.rb +145 -0
  56. data/lib/ace/coworker/molecules/instruction_resolver.rb +82 -0
  57. data/lib/ace/coworker/molecules/project_root.rb +26 -0
  58. data/lib/ace/coworker/molecules/report_state_store.rb +53 -0
  59. data/lib/ace/coworker/molecules/review_round_store.rb +115 -0
  60. data/lib/ace/coworker/molecules/role_preset_loader.rb +95 -0
  61. data/lib/ace/coworker/molecules/role_sequence_composer.rb +245 -0
  62. data/lib/ace/coworker/molecules/step_evidence_reader.rb +60 -0
  63. data/lib/ace/coworker/molecules/step_report_reader.rb +39 -0
  64. data/lib/ace/coworker/molecules/task_probe.rb +40 -0
  65. data/lib/ace/coworker/molecules/task_record_writer.rb +44 -0
  66. data/lib/ace/coworker/molecules/worktree_executor_probe.rb +139 -0
  67. data/lib/ace/coworker/molecules/worktree_provision_probe.rb +137 -0
  68. data/lib/ace/coworker/organisms/commission_orchestrator.rb +148 -0
  69. data/lib/ace/coworker/organisms/prepare_orchestrator.rb +374 -0
  70. data/lib/ace/coworker/organisms/report_orchestrator.rb +192 -0
  71. data/lib/ace/coworker/organisms/resume_orchestrator.rb +91 -0
  72. data/lib/ace/coworker/organisms/start_config_writer.rb +127 -0
  73. data/lib/ace/coworker/organisms/start_orchestrator.rb +45 -0
  74. data/lib/ace/coworker/organisms/status_orchestrator.rb +62 -0
  75. data/lib/ace/coworker/version.rb +7 -0
  76. data/lib/ace/coworker.rb +86 -0
  77. metadata +319 -0
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Atoms
6
+ # Pure validation of a raw role preset hash (the shipped
7
+ # .ace-defaults/coworker/roles/*.yml files and their project
8
+ # overrides). Field-specific errors, no IO, no defaults.
9
+ class RolePresetSchema
10
+ REQUIRED_FIELDS = %w[name instructions start_prompt].freeze
11
+
12
+ # @param raw [Hash, nil] parsed preset hash
13
+ # @return [Array<String>] field-specific error messages (empty when valid)
14
+ def self.errors(raw)
15
+ return ["preset is empty"] if raw.nil? || (!raw.is_a?(Hash) && raw.to_s.strip.empty?)
16
+ return ["preset must be a mapping, got #{raw.class}"] unless raw.is_a?(Hash)
17
+
18
+ errors = []
19
+ REQUIRED_FIELDS.each do |field|
20
+ value = raw[field]
21
+ if value.nil? || value.to_s.strip.empty?
22
+ errors << "missing required field '#{field}'"
23
+ end
24
+ end
25
+ errors.concat(non_empty_string_error(raw, "description")) if raw.key?("description")
26
+ errors.concat(nested_mapping_errors(raw, "reporting"))
27
+ errors.concat(nested_mapping_errors(raw, "errors"))
28
+ errors.concat(reporting_errors(raw))
29
+ errors.concat(error_rules_errors(raw))
30
+ errors
31
+ end
32
+
33
+ # Preset → config-shaped hash with the role's own fields only
34
+ # (identity fields are supplied by the commission, not the preset).
35
+ def self.normalize(raw)
36
+ {
37
+ "name" => str(raw["name"]),
38
+ "description" => str(raw["description"]),
39
+ "instructions" => str(raw["instructions"]),
40
+ "start_prompt" => raw["start_prompt"].to_s.strip,
41
+ "reporting" => nested_str(raw, "reporting", "channel"),
42
+ "errors" => nested_str(raw, "errors", "on_failure")
43
+ }
44
+ end
45
+
46
+ class << self
47
+ private
48
+
49
+ def non_empty_string_error(raw, field)
50
+ value = raw[field]
51
+ value.is_a?(String) && !value.strip.empty? ? [] : ["'#{field}' must be a non-empty string"]
52
+ end
53
+
54
+ def reporting_errors(raw)
55
+ channel = nested_str(raw, "reporting", "channel")
56
+ return [] if channel.empty?
57
+
58
+ ConfigSchema::CHANNELS.include?(channel) ? [] :
59
+ ["reporting.channel '#{channel}' is not one of: #{ConfigSchema::CHANNELS.join(', ')}"]
60
+ end
61
+
62
+ def error_rules_errors(raw)
63
+ on_failure = nested_str(raw, "errors", "on_failure")
64
+ return [] if on_failure.empty?
65
+
66
+ ConfigSchema::ON_FAILURE.include?(on_failure) ? [] :
67
+ ["errors.on_failure '#{on_failure}' is not one of: #{ConfigSchema::ON_FAILURE.join(', ')}"]
68
+ end
69
+
70
+ def nested_mapping_errors(raw, section)
71
+ value = raw[section]
72
+ return [] if value.nil? || value.is_a?(Hash)
73
+
74
+ ["'#{section}' must be a mapping, got #{value.class}"]
75
+ end
76
+
77
+ def nested_str(raw, section, key)
78
+ value = raw[section]
79
+ return "" unless value.is_a?(Hash)
80
+
81
+ str(value[key])
82
+ end
83
+
84
+ def str(value)
85
+ value.to_s.strip
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Atoms
6
+ # Pure validation of a single review-round record at the store
7
+ # boundary (Atoms::RolePresetSchema.errors pattern): field-specific
8
+ # errors, no IO, no coercion. A record must be a Hash with string
9
+ # keys; "type" full|delta, "status" completed|aborted, "confirmed"
10
+ # an array of hashes with string id+severity, "resolved" an array
11
+ # of strings. Garbage must surface as an actionable Ace::Coworker
12
+ # Error, never a TypeError or a silently wrong verdict.
13
+ module RoundRecordSchema
14
+ TYPES = %w[full delta].freeze
15
+ STATUSES = %w[completed aborted].freeze
16
+
17
+ # @param record [Object] candidate round record
18
+ # @return [Array<String>] field-specific error messages (empty when valid)
19
+ def self.errors(record)
20
+ return ["round record must be a Hash with string keys, got #{record.class}"] unless record.is_a?(Hash)
21
+ return ["round record keys must be strings, got: #{record.keys.inspect}"] if record.keys.any? { |k| !k.is_a?(String) }
22
+
23
+ errors = []
24
+ errors << "'type' must be one of: #{TYPES.join(', ')}" unless TYPES.include?(record["type"])
25
+ errors << "'status' must be one of: #{STATUSES.join(', ')}" unless STATUSES.include?(record["status"])
26
+ errors.concat(confirmed_errors(record["confirmed"]))
27
+ errors.concat(resolved_errors(record["resolved"]))
28
+ errors
29
+ end
30
+
31
+ class << self
32
+ private
33
+
34
+ def confirmed_errors(value)
35
+ return ["'confirmed' must be an array, got #{value.class}"] unless value.is_a?(Array)
36
+
37
+ value.each_with_index.flat_map do |finding, index|
38
+ if !finding.is_a?(Hash)
39
+ ["'confirmed[#{index}]' must be a Hash, got #{finding.class}"]
40
+ elsif finding.keys.any? { |k| !k.is_a?(String) }
41
+ ["'confirmed[#{index}]' keys must be strings"]
42
+ elsif !finding["id"].is_a?(String) || finding["id"].empty? ||
43
+ !finding["severity"].is_a?(String) || finding["severity"].empty?
44
+ ["'confirmed[#{index}]' must have non-empty string 'id' and 'severity'"]
45
+ else
46
+ []
47
+ end
48
+ end
49
+ end
50
+
51
+ def resolved_errors(value)
52
+ return ["'resolved' must be an array, got #{value.class}"] unless value.is_a?(Array)
53
+
54
+ value.each_with_index.map do |id, index|
55
+ id.is_a?(String) && !id.empty? ? nil : "'resolved[#{index}]' must be a non-empty string"
56
+ end.compact
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module Atoms
8
+ # Pure 3/2/10 review-convergence rule (8xg.t.sbz.4.2 / contract):
9
+ # minimum 3 full rounds, the last 2 consecutive completed full rounds
10
+ # without confirmed High/Critical, and no earlier unresolved
11
+ # High/Critical; maximum 10 full rounds - satisfying the rule at
12
+ # round 10 succeeds, otherwise the verdict is :escalate (an explicit
13
+ # outcome for the reviewer role to route via ace-hitl, never an
14
+ # exception). Delta/fix-diff rounds never advance the counters; the
15
+ # caller records a delta round that still reviews every changed part
16
+ # as type "full" (reviewer.wf.md rule 6). Interrupted/aborted rounds
17
+ # are not clean. No IO, no writes.
18
+ module RoundRule
19
+ MIN_FULL_ROUNDS = 3
20
+ MAX_FULL_ROUNDS = 10
21
+ REQUIRED_CLEAN_STREAK = 2
22
+ HIGH_SEVERITIES = %w[high critical].freeze
23
+
24
+ # @param records [Array<Hash>] ordered round records, each with:
25
+ # "type" => "full" | "delta",
26
+ # "status" => "completed" | "aborted",
27
+ # "confirmed" => Array<{"id" => String, "severity" => String}>,
28
+ # "resolved" => Array<String> finding ids resolved by this round
29
+ # @return [Hash] {verdict: :continue | :accept | :escalate,
30
+ # full_rounds:, completed_full_rounds:, consecutive_clean:,
31
+ # unresolved_high_critical: [ids]}
32
+ def self.evaluate(records)
33
+ records = Array(records)
34
+ resolved_ids = records.flat_map { |r| list(r["resolved"]) }.to_set
35
+ unresolved = confirmed_high(records).reject { |id| resolved_ids.include?(id) }.uniq
36
+
37
+ full_rounds = records.count { |r| full?(r) }
38
+ completed_full = records.count { |r| full?(r) && completed?(r) }
39
+ streak = clean_streak(records)
40
+
41
+ # The 10-round cap is terminal: acceptance is possible only at
42
+ # or before round 10, so a satisfying state that materializes
43
+ # at round 11+ escalates instead of flipping back to :accept.
44
+ acceptable = completed_full >= MIN_FULL_ROUNDS &&
45
+ streak >= REQUIRED_CLEAN_STREAK && unresolved.empty? &&
46
+ full_rounds <= MAX_FULL_ROUNDS
47
+ verdict = if acceptable
48
+ :accept
49
+ elsif full_rounds >= MAX_FULL_ROUNDS
50
+ :escalate
51
+ else
52
+ :continue
53
+ end
54
+
55
+ {verdict: verdict, full_rounds: full_rounds,
56
+ completed_full_rounds: completed_full, consecutive_clean: streak,
57
+ unresolved_high_critical: unresolved}
58
+ end
59
+
60
+ # A record is a clean round only when it is a completed full round
61
+ # with no confirmed High/Critical finding; aborted full rounds and
62
+ # full rounds with High/Critical reset the streak. Delta rounds
63
+ # never advance the streak, but a delta that CONFIRMS a
64
+ # High/Critical finding breaks it — even when the same delta (or a
65
+ # later fix delta) resolves the finding: code that just produced a
66
+ # High defect needs renewed clean full-round evidence, never the
67
+ # stale streak from before the finding. Deltas without a confirmed
68
+ # High/Critical (e.g. a pure fix delta) stay transparent.
69
+ def self.clean_streak(records)
70
+ streak = 0
71
+ records.reverse_each do |record|
72
+ unless full?(record)
73
+ break if confirmed_high([record]).any?
74
+
75
+ next
76
+ end
77
+
78
+ break unless completed?(record) && confirmed_high([record]).empty?
79
+
80
+ streak += 1
81
+ end
82
+ streak
83
+ end
84
+
85
+ def self.confirmed_high(records)
86
+ records.flat_map { |r| list(r["confirmed"]) }
87
+ .select { |f| HIGH_SEVERITIES.include?(f["severity"].to_s.downcase) }
88
+ .map { |f| f["id"].to_s }
89
+ end
90
+
91
+ def self.full?(record)
92
+ record["type"].to_s == "full"
93
+ end
94
+
95
+ def self.completed?(record)
96
+ record["status"].to_s == "completed"
97
+ end
98
+
99
+ def self.list(value)
100
+ Array(value)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module CLI
8
+ module Commands
9
+ class Commission < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Instantiate a role preset into the coworker run config"
13
+
14
+ option :role, desc: "Role preset name (required)"
15
+ option :task, desc: "Task reference that must resolve in this worktree (required)"
16
+ option :assignment, desc: "Assignment id pinned to the role (required)"
17
+ option :overseer, desc: "Overseer reporting address (required)"
18
+ option :"start-prompt", desc: "Literal start prompt overriding the preset template"
19
+ option :config, desc: "Config path (default from cascade: .ace-local/coworker/config.yml)"
20
+ option :force, type: :boolean, default: false, desc: "Allow overwriting an existing config"
21
+ option :"dry-run", type: :boolean, default: false,
22
+ desc: "Render the resolved config without writing"
23
+ option :format, default: "table", desc: "Output format (table, json)"
24
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
25
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
26
+
27
+ def initialize(orchestrator: nil)
28
+ super()
29
+ @orchestrator = orchestrator
30
+ end
31
+
32
+ def call(role: nil, task: nil, assignment: nil, overseer: nil, **options)
33
+ role = role.to_s.strip
34
+ task = task.to_s.strip
35
+ assignment = assignment.to_s.strip
36
+ overseer = overseer.to_s.strip
37
+ required = [["--role", role], ["--task", task],
38
+ ["--assignment", assignment], ["--overseer", overseer]].each_with_object([]) do |(flag, value), missing|
39
+ missing << flag if value.empty?
40
+ end
41
+ unless required.empty?
42
+ raise Ace::Support::Cli::Error,
43
+ "#{required.join(', ')} #{required.size == 1 ? 'is' : 'are'} required. " \
44
+ "Usage: ace-coworker commission --role worker --task <ref> --assignment <id> --overseer <addr>"
45
+ end
46
+
47
+ orchestrator = @orchestrator || build_orchestrator(options[:config])
48
+ result = orchestrator.call(
49
+ role: role, task: task, assignment: assignment, overseer: overseer,
50
+ start_prompt: options[:"start-prompt"],
51
+ force: options[:force] == true, dry_run: options[:"dry-run"] == true
52
+ )
53
+
54
+ return if options[:quiet]
55
+
56
+ render(result, options[:format])
57
+ rescue Ace::Coworker::Error => e
58
+ raise Ace::Support::Cli::Error, e.message
59
+ end
60
+
61
+ private
62
+
63
+ def build_orchestrator(config_path)
64
+ project_root = Molecules::ProjectRoot.new.resolve
65
+ Organisms::CommissionOrchestrator.new(
66
+ project_root: project_root,
67
+ preset_loader: Molecules::RolePresetLoader.new(project_root: project_root),
68
+ task_probe: Molecules::TaskProbe.new(project_root: project_root),
69
+ assignment_metadata: Molecules::AssignmentMetadata.new(project_root: project_root),
70
+ config_path: config_path
71
+ )
72
+ end
73
+
74
+ def render(result, format)
75
+ if format == "json"
76
+ puts JSON.pretty_generate(result.to_h)
77
+ else
78
+ puts render_table(result)
79
+ end
80
+ end
81
+
82
+ def render_table(result)
83
+ lines = ["Coworker commission"]
84
+ lines << " role: #{result.role} (preset source: #{result.preset_source})"
85
+ lines << " task: #{result.task}"
86
+ lines << " assignment: #{result.assignment}"
87
+ lines << " overseer: #{result.overseer}"
88
+ lines << " config_path: #{result.config_path}"
89
+ lines << format(" result: %s", result.dry_run ? "resolved (dry-run, nothing written)" : "written")
90
+ result.config.each do |key, value|
91
+ rendered = value.is_a?(Hash) ? value.map { |k, v| "#{k}=#{v}" }.join(" ") : value
92
+ lines << " #{key}: #{rendered}"
93
+ end
94
+ lines.join("\n")
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module CLI
8
+ module Commands
9
+ class Gate < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Evaluate the pre-merge integration gate and print :ready or :blocked with reasons"
13
+
14
+ example [
15
+ "gate --candidate-head 1a2b3c4 --review-head 1a2b3c4 --review-verdict accept \\",
16
+ " --tests executed,passed --dep 8xg.t.sbz.4.2=integrated",
17
+ "gate --candidate-head 1a2b3c4 --review-head 0d0e0f --review-verdict accept \\",
18
+ " --tests executed,passed --dep 8xg.t.sbz.4.2=missing"
19
+ ]
20
+
21
+ option :candidate_head, desc: "Current head of the candidate branch (omit when unknown)"
22
+ option :review_head, desc: "Candidate head the review verdict covers"
23
+ option :review_verdict, required: true, desc: "Independent review verdict (accept, continue, escalate)"
24
+ option :tests, type: :array, default: [], desc: "Test evidence flags: executed, passed"
25
+ option :dep, type: :array, default: [], desc: "Dependency states as name=integrated|missing pairs"
26
+ option :format, default: "table", desc: "Output format (table, json)"
27
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
28
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
29
+
30
+ def call(candidate_head: nil, review_head: nil, review_verdict: nil, tests: [], dep: [],
31
+ format: "table", **options)
32
+ result = Atoms::IntegrationGate.evaluate(
33
+ "candidate_head" => candidate_head.to_s,
34
+ "review" => {"head" => review_head.to_s, "verdict" => review_verdict.to_s},
35
+ "tests" => tests_flags(tests),
36
+ "dependencies" => dependency_states(dep)
37
+ )
38
+
39
+ return if options[:quiet]
40
+
41
+ if format == "json"
42
+ puts JSON.pretty_generate(json_payload(result))
43
+ else
44
+ puts render(result)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def tests_flags(tests)
51
+ flags = Array(tests).flat_map { |value| value.to_s.split(",") }.map { |f| f.strip.downcase }.reject(&:empty?)
52
+ flags.each do |flag|
53
+ unless %w[executed passed].include?(flag)
54
+ raise Ace::Support::Cli::Error, "invalid --tests '#{flag}' (expected: executed, passed)"
55
+ end
56
+ end
57
+ {"executed" => flags.include?("executed"), "passed" => flags.include?("passed")}
58
+ end
59
+
60
+ def dependency_states(dep)
61
+ Array(dep).each_with_object({}) do |pair, states|
62
+ name, state = pair.to_s.split("=", 2)
63
+ name = name.to_s.strip
64
+ if name.empty? || state.nil?
65
+ raise Ace::Support::Cli::Error, "invalid --dep '#{pair}' (expected name=integrated|missing)"
66
+ end
67
+ raise Ace::Support::Cli::Error, "duplicate --dep '#{name}'" if states.key?(name)
68
+
69
+ states[name] = state.strip.downcase == "integrated"
70
+ end
71
+ end
72
+
73
+ def json_payload(result)
74
+ {
75
+ "verdict" => result[:verdict].to_s,
76
+ "reasons" => result[:reasons],
77
+ "failed_checks" => result[:failed_checks].map(&:to_s)
78
+ }
79
+ end
80
+
81
+ def render(result)
82
+ lines = ["Coworker integration gate"]
83
+ lines << " verdict: #{result[:verdict]}"
84
+ result[:reasons].each { |reason| lines << " blocked: #{reason}" }
85
+ lines.join("\n")
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module CLI
8
+ module Commands
9
+ class Prepare < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Accept a preparation commission and ensure the task worktree"
13
+
14
+ option :task, desc: "Task reference (required)"
15
+ option :roles, type: :array,
16
+ desc: "Expected role sequence, repeatable and comma-separated (e.g., --roles worker,reviewer)"
17
+ option :to, desc: "Escalation recipient (default: coworker config overseer)"
18
+ option :config, desc: "Path to coworker config for the overseer fallback"
19
+ option :"dry-run", type: :boolean, default: false,
20
+ desc: "Resolve and detect conflicts without any writes"
21
+ option :format, default: "table", desc: "Output format (table, json)"
22
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
23
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
24
+
25
+ def initialize(orchestrator: nil)
26
+ super()
27
+ @orchestrator = orchestrator
28
+ end
29
+
30
+ def call(task: nil, roles: nil, **options)
31
+ task_ref = task.to_s.strip
32
+ raise Ace::Support::Cli::Error, "--task is required. Usage: ace-coworker prepare --task <ref>" if task_ref.empty?
33
+
34
+ role_list = normalize_roles(roles)
35
+ if role_list.empty?
36
+ raise Ace::Support::Cli::Error,
37
+ "--roles is required. Usage: ace-coworker prepare --task <ref> --roles worker,reviewer"
38
+ end
39
+
40
+ orchestrator = @orchestrator || Organisms::PrepareOrchestrator.new
41
+ result = orchestrator.call(task_ref: task_ref, roles: role_list,
42
+ to: options[:to], config_path: options[:config],
43
+ dry_run: options[:"dry-run"] == true)
44
+
45
+ # Exit semantics first: quiet suppresses output, never the
46
+ # non-zero exit a conflict must keep.
47
+ if result.conflict?
48
+ render(result, options[:format]) unless options[:quiet]
49
+ conflict = result.conflict
50
+ raise Ace::Support::Cli::Error,
51
+ "preparation conflict: #{conflict['kind']} - #{first_line(conflict['detail'])}"
52
+ end
53
+
54
+ return if options[:quiet]
55
+
56
+ render(result, options[:format])
57
+ rescue Ace::Coworker::Error => e
58
+ raise Ace::Support::Cli::Error, e.message
59
+ end
60
+
61
+ private
62
+
63
+ def normalize_roles(raw_roles)
64
+ Array(raw_roles)
65
+ .flat_map { |entry| entry.to_s.split(",") }
66
+ .map(&:strip)
67
+ .reject(&:empty?)
68
+ .uniq
69
+ end
70
+
71
+ def first_line(text)
72
+ text.to_s.lines.first.to_s.strip
73
+ end
74
+
75
+ def report_line(report)
76
+ return "not sent (#{report['error']})" if report["error"]
77
+
78
+ delivery = report["delivered"] ? "delivered" : "not delivered (retry: ace-hitl send #{report['event']})"
79
+ "readiness event #{report['event']} (#{delivery})"
80
+ end
81
+
82
+ def render(result, format)
83
+ if format == "json"
84
+ puts JSON.pretty_generate(result.to_h)
85
+ else
86
+ puts render_table(result)
87
+ end
88
+ end
89
+
90
+ def render_table(result)
91
+ lines = ["Coworker prepare"]
92
+ lines << " task: #{result.task}"
93
+ lines << " task_path: #{result.task_path}"
94
+ lines << " roles: #{result.roles.join(', ')}"
95
+ if result.conflict?
96
+ conflict = result.conflict
97
+ lines << " conflict: #{conflict['kind']}"
98
+ conflict["detail"].to_s.lines.map(&:strip).reject(&:empty?).each do |line|
99
+ lines << " #{line}"
100
+ end
101
+ if conflict["hitl_event"]
102
+ delivery = conflict["delivered"] ? "delivered" : "not delivered (retry: ace-hitl send #{conflict['hitl_event']})"
103
+ lines << " event: #{conflict['hitl_event']} (#{delivery})"
104
+ end
105
+ else
106
+ worktree = result.worktree
107
+ lines << " worktree: #{worktree['path']}"
108
+ lines << " branch: #{worktree['branch']}"
109
+ lines << format(" result: %s", worktree["disposition"])
110
+ end
111
+ unless result.assignments.empty?
112
+ lines << " sequence:"
113
+ result.assignments.each do |entry|
114
+ lines << " #{entry['position']}/#{entry['of']} #{entry['role']}: " \
115
+ "#{entry['assignment']} (#{entry['disposition']}, #{entry['steps']} steps)"
116
+ lines << " expected outcome: #{entry['outcome']}"
117
+ end
118
+ end
119
+ unless result.start_configs.empty?
120
+ lines << " start configs:"
121
+ result.start_configs.each do |entry|
122
+ lines << " #{entry['role']}: #{entry['path']} (#{entry['disposition']}, " \
123
+ "assignment #{entry['assignment']})"
124
+ end
125
+ end
126
+ if result.report
127
+ lines << " report: #{report_line(result.report)}"
128
+ end
129
+ result.worktree_assignments.each do |assignment|
130
+ lines << " assignment: #{assignment['id']} #{assignment['state']} (#{assignment['progress']})"
131
+ end
132
+ lines.join("\n")
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module CLI
8
+ module Commands
9
+ class Report < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Record the assignment outcome and report it through ace-hitl"
13
+
14
+ option :config, desc: "Path to coworker config (default: .ace-local/coworker/config.yml)"
15
+ option :assignment, desc: "Assignment ID (default: config assignment)"
16
+ option :result, desc: "Force result (success, error); default: derived from the ace-assign verdict"
17
+ option :message, desc: "Explicit failure cause (required for error without step evidence)"
18
+ option :step, desc: "Step ref to report (default: failed step / last step)"
19
+ option :"no-send", type: :boolean, desc: "Create the HITL event without delivering it"
20
+ option :"dry-run", type: :boolean, desc: "Show the derived outcome; nothing is written or sent"
21
+ option :format, default: "table", desc: "Output format (table, json)"
22
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
23
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
24
+
25
+ def call(**options)
26
+ orchestrator = Organisms::ReportOrchestrator.new
27
+ result = orchestrator.call(
28
+ config_path: options[:config],
29
+ assignment: options[:assignment],
30
+ result: options[:result],
31
+ message: options[:message],
32
+ step: options[:step],
33
+ no_send: options[:"no-send"] == true,
34
+ dry_run: options[:"dry-run"] == true
35
+ )
36
+
37
+ # Exit semantics first: quiet suppresses output, never the
38
+ # non-zero exit an error outcome or delivery failure must keep.
39
+ if result.exit_code != 0
40
+ raise Ace::Support::Cli::Error, result.message
41
+ end
42
+
43
+ return if options[:quiet]
44
+
45
+ if options[:format] == "json"
46
+ puts JSON.pretty_generate(to_h(result))
47
+ else
48
+ puts render(result)
49
+ end
50
+ rescue Ace::Coworker::Error => e
51
+ raise Ace::Support::Cli::Error, e.message
52
+ end
53
+
54
+ private
55
+
56
+ def to_h(result)
57
+ {
58
+ "result" => result.outcome.result,
59
+ "record" => result.record_path,
60
+ "event_id" => result.event_id,
61
+ "delivery" => result.delivery,
62
+ "message" => result.message
63
+ }
64
+ end
65
+
66
+ def render(result)
67
+ lines = ["Coworker report"]
68
+ lines << " outcome: #{result.outcome.result}"
69
+ lines << " record: #{result.record_path}" if result.record_path
70
+ lines << " event: #{result.event_id}" if result.event_id
71
+ lines << " delivery: #{result.delivery}" if result.delivery
72
+ lines << " #{result.message}" if result.message
73
+ lines.join("\n")
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end