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.
- checksums.yaml +7 -0
- data/.ace-defaults/coworker/config.yml +6 -0
- data/.ace-defaults/coworker/roles/integrator.yml +15 -0
- data/.ace-defaults/coworker/roles/reviewer.yml +15 -0
- data/.ace-defaults/coworker/roles/spec.yml +14 -0
- data/.ace-defaults/coworker/roles/worker.yml +13 -0
- data/.ace-defaults/coworker/sequences/integrator.yml +20 -0
- data/.ace-defaults/coworker/sequences/reviewer.yml +20 -0
- data/.ace-defaults/coworker/sequences/spec.yml +16 -0
- data/.ace-defaults/coworker/sequences/worker.yml +21 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-coworker.yml +19 -0
- data/CHANGELOG.md +255 -0
- data/README.md +98 -0
- data/Rakefile +13 -0
- data/docs/usage.md +564 -0
- data/exe/ace-coworker +16 -0
- data/handbook/workflow-instructions/coworker/integrator.wf.md +162 -0
- data/handbook/workflow-instructions/coworker/reviewer.wf.md +211 -0
- data/handbook/workflow-instructions/coworker/spec.wf.md +100 -0
- data/handbook/workflow-instructions/coworker/worker.wf.md +103 -0
- data/lib/ace/coworker/atoms/active_executor_rule.rb +71 -0
- data/lib/ace/coworker/atoms/brief_formatter.rb +33 -0
- data/lib/ace/coworker/atoms/config_schema.rb +121 -0
- data/lib/ace/coworker/atoms/integration_gate.rb +92 -0
- data/lib/ace/coworker/atoms/outcome_planner.rb +79 -0
- data/lib/ace/coworker/atoms/prepare_decision.rb +75 -0
- data/lib/ace/coworker/atoms/prompt_template.rb +28 -0
- data/lib/ace/coworker/atoms/report_record_formatter.rb +60 -0
- data/lib/ace/coworker/atoms/resume_brief_formatter.rb +84 -0
- data/lib/ace/coworker/atoms/resume_planner.rb +78 -0
- data/lib/ace/coworker/atoms/role_preset_schema.rb +91 -0
- data/lib/ace/coworker/atoms/round_record_schema.rb +62 -0
- data/lib/ace/coworker/atoms/round_rule.rb +105 -0
- data/lib/ace/coworker/cli/commands/commission.rb +100 -0
- data/lib/ace/coworker/cli/commands/gate.rb +91 -0
- data/lib/ace/coworker/cli/commands/prepare.rb +138 -0
- data/lib/ace/coworker/cli/commands/report.rb +79 -0
- data/lib/ace/coworker/cli/commands/resume.rb +57 -0
- data/lib/ace/coworker/cli/commands/roles.rb +60 -0
- data/lib/ace/coworker/cli/commands/rounds.rb +99 -0
- data/lib/ace/coworker/cli/commands/start.rb +39 -0
- data/lib/ace/coworker/cli/commands/status.rb +95 -0
- data/lib/ace/coworker/cli.rb +83 -0
- data/lib/ace/coworker/errors.rb +10 -0
- data/lib/ace/coworker/models/assignment_snapshot.rb +65 -0
- data/lib/ace/coworker/models/brief.rb +38 -0
- data/lib/ace/coworker/models/config.rb +54 -0
- data/lib/ace/coworker/models/outcome.rb +39 -0
- data/lib/ace/coworker/models/preparation.rb +67 -0
- data/lib/ace/coworker/models/resume_plan.rb +47 -0
- data/lib/ace/coworker/molecules/assignment_metadata.rb +57 -0
- data/lib/ace/coworker/molecules/assignment_probe.rb +57 -0
- data/lib/ace/coworker/molecules/config_loader.rb +119 -0
- data/lib/ace/coworker/molecules/git_state_probe.rb +63 -0
- data/lib/ace/coworker/molecules/hitl_reporter.rb +145 -0
- data/lib/ace/coworker/molecules/instruction_resolver.rb +82 -0
- data/lib/ace/coworker/molecules/project_root.rb +26 -0
- data/lib/ace/coworker/molecules/report_state_store.rb +53 -0
- data/lib/ace/coworker/molecules/review_round_store.rb +115 -0
- data/lib/ace/coworker/molecules/role_preset_loader.rb +95 -0
- data/lib/ace/coworker/molecules/role_sequence_composer.rb +245 -0
- data/lib/ace/coworker/molecules/step_evidence_reader.rb +60 -0
- data/lib/ace/coworker/molecules/step_report_reader.rb +39 -0
- data/lib/ace/coworker/molecules/task_probe.rb +40 -0
- data/lib/ace/coworker/molecules/task_record_writer.rb +44 -0
- data/lib/ace/coworker/molecules/worktree_executor_probe.rb +139 -0
- data/lib/ace/coworker/molecules/worktree_provision_probe.rb +137 -0
- data/lib/ace/coworker/organisms/commission_orchestrator.rb +148 -0
- data/lib/ace/coworker/organisms/prepare_orchestrator.rb +374 -0
- data/lib/ace/coworker/organisms/report_orchestrator.rb +192 -0
- data/lib/ace/coworker/organisms/resume_orchestrator.rb +91 -0
- data/lib/ace/coworker/organisms/start_config_writer.rb +127 -0
- data/lib/ace/coworker/organisms/start_orchestrator.rb +45 -0
- data/lib/ace/coworker/organisms/status_orchestrator.rb +62 -0
- data/lib/ace/coworker/version.rb +7 -0
- data/lib/ace/coworker.rb +86 -0
- 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
|