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,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Coworker
|
|
5
|
+
module Atoms
|
|
6
|
+
# Renders the executor brief (table or json). Pure formatting.
|
|
7
|
+
class BriefFormatter
|
|
8
|
+
def self.format_table(brief)
|
|
9
|
+
h = brief.to_h
|
|
10
|
+
lines = []
|
|
11
|
+
lines << "Coworker brief"
|
|
12
|
+
lines << " role: #{h['role']}"
|
|
13
|
+
lines << " instructions: #{h['instructions']}"
|
|
14
|
+
lines << " instructions src: #{h['instructions_source']}"
|
|
15
|
+
lines << " start prompt: #{h['start_prompt']}"
|
|
16
|
+
lines << " task: #{h['task']} (#{h['task_path']})"
|
|
17
|
+
lines << " assignment: drive with --assignment #{h['assignment']}"
|
|
18
|
+
lines << " assignment state: #{h['assignment_state']} (#{h['assignment_progress']})"
|
|
19
|
+
lines << " overseer: #{h['overseer']} (channel: #{h['reporting_channel']})"
|
|
20
|
+
lines << " on failure: #{h['on_failure']}"
|
|
21
|
+
lines << " report with: ace-coworker report --assignment #{h['assignment']}"
|
|
22
|
+
lines << " project root: #{h['project_root']}"
|
|
23
|
+
lines.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.format_json(brief)
|
|
27
|
+
require "json"
|
|
28
|
+
JSON.pretty_generate(brief.to_h)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Coworker
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure validation of a raw coworker config hash.
|
|
7
|
+
#
|
|
8
|
+
# Produces field-specific errors; no IO, no guesses, no defaults
|
|
9
|
+
# beyond the documented ones (reporting.channel, errors.on_failure).
|
|
10
|
+
class ConfigSchema
|
|
11
|
+
REQUIRED_FIELDS = %w[role instructions task assignment overseer].freeze
|
|
12
|
+
PROTOCOL_PREFIXES = %w[wfi:// prompt:// tmpl://].freeze
|
|
13
|
+
CHANNELS = %w[hitl].freeze
|
|
14
|
+
ON_FAILURE = %w[report stop].freeze
|
|
15
|
+
|
|
16
|
+
# @param raw [Hash, nil] parsed config hash
|
|
17
|
+
# @return [Array<String>] field-specific error messages (empty when valid)
|
|
18
|
+
def self.errors(raw)
|
|
19
|
+
return ["config is empty"] if raw.nil? || (!raw.is_a?(Hash) && raw.to_s.strip.empty?)
|
|
20
|
+
return ["config must be a mapping, got #{raw.class}"] unless raw.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
errors = []
|
|
23
|
+
REQUIRED_FIELDS.each do |field|
|
|
24
|
+
value = raw[field]
|
|
25
|
+
if value.nil? || value.to_s.strip.empty?
|
|
26
|
+
errors << "missing required field '#{field}'"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
errors.concat(prompt_errors(raw))
|
|
31
|
+
errors.concat(nested_mapping_errors(raw, "reporting"))
|
|
32
|
+
errors.concat(nested_mapping_errors(raw, "errors"))
|
|
33
|
+
errors.concat(reporting_errors(raw))
|
|
34
|
+
errors.concat(error_rules_errors(raw))
|
|
35
|
+
errors
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param raw [Hash] validated config hash
|
|
39
|
+
# @return [Hash] normalized hash with defaults applied.
|
|
40
|
+
# start_prompt stays literal; the start_prompt_file CONTENT is read
|
|
41
|
+
# by the loader (IO does not belong in this pure atom).
|
|
42
|
+
def self.normalize(raw)
|
|
43
|
+
{
|
|
44
|
+
"role" => str(raw["role"]),
|
|
45
|
+
"instructions" => str(raw["instructions"]),
|
|
46
|
+
"start_prompt" => str(raw["start_prompt"]),
|
|
47
|
+
"start_prompt_file" => str(raw["start_prompt_file"]),
|
|
48
|
+
"task" => str(raw["task"]),
|
|
49
|
+
"assignment" => str(raw["assignment"]),
|
|
50
|
+
"overseer" => str(raw["overseer"]),
|
|
51
|
+
"reporting" => {"channel" => nested_str(raw, "reporting", "channel").empty? ? "hitl" : nested_str(raw, "reporting", "channel")},
|
|
52
|
+
"errors" => {"on_failure" => nested_str(raw, "errors", "on_failure").empty? ? "report" : nested_str(raw, "errors", "on_failure")}
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Protocol instructions reference an external resource; plain paths
|
|
57
|
+
# are files resolved against the project root.
|
|
58
|
+
def self.protocol?(instructions)
|
|
59
|
+
PROTOCOL_PREFIXES.any? { |prefix| instructions.to_s.start_with?(prefix) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.errors_for_field(raw, field)
|
|
63
|
+
errors(raw).select { |e| e.include?("'#{field}'") }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.prompt_errors(raw)
|
|
67
|
+
prompt = str(raw["start_prompt"])
|
|
68
|
+
prompt_file = str(raw["start_prompt_file"])
|
|
69
|
+
if prompt.empty? && prompt_file.empty?
|
|
70
|
+
["missing required field 'start_prompt' (or 'start_prompt_file')"]
|
|
71
|
+
elsif !prompt.empty? && !prompt_file.empty?
|
|
72
|
+
["'start_prompt' and 'start_prompt_file' are mutually exclusive"]
|
|
73
|
+
else
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
private_class_method :prompt_errors
|
|
78
|
+
|
|
79
|
+
def self.reporting_errors(raw)
|
|
80
|
+
channel = nested_str(raw, "reporting", "channel")
|
|
81
|
+
return [] if channel.empty?
|
|
82
|
+
|
|
83
|
+
CHANNELS.include?(channel) ? [] : ["reporting.channel '#{channel}' is not one of: #{CHANNELS.join(', ')}"]
|
|
84
|
+
end
|
|
85
|
+
private_class_method :reporting_errors
|
|
86
|
+
|
|
87
|
+
def self.error_rules_errors(raw)
|
|
88
|
+
on_failure = nested_str(raw, "errors", "on_failure")
|
|
89
|
+
return [] if on_failure.empty?
|
|
90
|
+
|
|
91
|
+
ON_FAILURE.include?(on_failure) ? [] : ["errors.on_failure '#{on_failure}' is not one of: #{ON_FAILURE.join(', ')}"]
|
|
92
|
+
end
|
|
93
|
+
private_class_method :error_rules_errors
|
|
94
|
+
|
|
95
|
+
# Nested sections must be mappings (or absent); scalars such as
|
|
96
|
+
# `reporting: hitl` would raise TypeError in #dig.
|
|
97
|
+
def self.nested_mapping_errors(raw, section)
|
|
98
|
+
value = raw[section]
|
|
99
|
+
return [] if value.nil? || value.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
["'#{section}' must be a mapping, got #{value.class}"]
|
|
102
|
+
end
|
|
103
|
+
private_class_method :nested_mapping_errors
|
|
104
|
+
|
|
105
|
+
# TypeError-safe nested string read for validated mappings.
|
|
106
|
+
def self.nested_str(raw, section, key)
|
|
107
|
+
value = raw[section]
|
|
108
|
+
return "" unless value.is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
str(value[key])
|
|
111
|
+
end
|
|
112
|
+
private_class_method :nested_str
|
|
113
|
+
|
|
114
|
+
def self.str(value)
|
|
115
|
+
value.to_s.strip
|
|
116
|
+
end
|
|
117
|
+
private_class_method :str
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Coworker
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure pre-merge integration gate (8xg.t.sbz.4.3 / contract): the
|
|
7
|
+
# integrator merges only a candidate whose (a) dependencies are
|
|
8
|
+
# integrated predecessors, (b) current head matches the reviewed
|
|
9
|
+
# head (no drift), (c) tests were executed and passed, and (d) a
|
|
10
|
+
# fresh independent review verdict accepts the EXACT candidate
|
|
11
|
+
# head. Any unknown or contradictory fact is a blocking reason -
|
|
12
|
+
# uncertainty must be verified against the real worktree/reports,
|
|
13
|
+
# never guessed or retried blind. Explicit :blocked outcome with
|
|
14
|
+
# reasons, never an exception. No IO, no writes, no merges: the
|
|
15
|
+
# gate decides readiness only; the role drives git/ace-git.
|
|
16
|
+
module IntegrationGate
|
|
17
|
+
ACCEPT_VERDICTS = %w[accept].freeze
|
|
18
|
+
|
|
19
|
+
# @param input [Hash] string-keyed facts:
|
|
20
|
+
# "candidate_head" => String (current head of the candidate)
|
|
21
|
+
# "review" => {"head" => String reviewed head, "verdict" => String}
|
|
22
|
+
# "tests" => {"executed" => bool, "passed" => bool}
|
|
23
|
+
# "dependencies" => {name => bool (integrated predecessor)}
|
|
24
|
+
# @return [Hash] {verdict: :ready | :blocked, reasons: [String],
|
|
25
|
+
# failed_checks: [:dependencies|:candidate|:review|:tests|:input]}
|
|
26
|
+
def self.evaluate(input)
|
|
27
|
+
unless input.is_a?(Hash) && input.keys.all? { |k| k.is_a?(String) }
|
|
28
|
+
return {verdict: :blocked,
|
|
29
|
+
reasons: ["gate input must be a Hash with string keys, got #{input.class}"],
|
|
30
|
+
failed_checks: [:input]}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
reasons = []
|
|
34
|
+
failed = []
|
|
35
|
+
|
|
36
|
+
candidate_head = str(input["candidate_head"])
|
|
37
|
+
if candidate_head.empty?
|
|
38
|
+
reasons << "candidate head unknown: verify the actual worktree head instead of guessing"
|
|
39
|
+
failed << :candidate
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
dependencies = hash(input["dependencies"])
|
|
43
|
+
unsatisfied = dependencies.reject { |_, integrated| integrated == true }
|
|
44
|
+
unless unsatisfied.empty?
|
|
45
|
+
reasons << "dependencies not integrated: #{unsatisfied.keys.sort.join(', ')}"
|
|
46
|
+
failed << :dependencies
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
review = hash(input["review"])
|
|
50
|
+
review_head = str(review["head"])
|
|
51
|
+
verdict = str(review["verdict"]).downcase
|
|
52
|
+
if review_head.empty?
|
|
53
|
+
reasons << "review evidence missing: no reviewed head recorded - the verdict covers no verifiable candidate"
|
|
54
|
+
failed << :review
|
|
55
|
+
elsif !candidate_head.empty? && review_head.downcase != candidate_head.downcase
|
|
56
|
+
reasons << "stale review: candidate #{candidate_head} is not the reviewed head #{review_head}"
|
|
57
|
+
failed << :review
|
|
58
|
+
end
|
|
59
|
+
unless ACCEPT_VERDICTS.include?(verdict)
|
|
60
|
+
reasons << "review verdict is '#{verdict.empty? ? 'unknown' : verdict}', not accept"
|
|
61
|
+
failed << :review
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
tests = hash(input["tests"])
|
|
65
|
+
unless tests["executed"] == true
|
|
66
|
+
reasons << "tests not executed: real executed-test evidence is required before merge"
|
|
67
|
+
failed << :tests
|
|
68
|
+
end
|
|
69
|
+
unless tests["passed"] == true
|
|
70
|
+
reasons << "tests not passing: executed tests failed or their outcome is unknown"
|
|
71
|
+
failed << :tests
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
{verdict: reasons.empty? ? :ready : :blocked,
|
|
75
|
+
reasons: reasons, failed_checks: failed.uniq}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class << self
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def str(value)
|
|
82
|
+
value.to_s.strip
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def hash(value)
|
|
86
|
+
value.is_a?(Hash) ? value : {}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Coworker
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure derivation of the reporting Outcome from an AssignmentSnapshot.
|
|
7
|
+
# ace-assign is the only truth: a success report requires a complete
|
|
8
|
+
# verdict; an error report requires a cause and never claims work done.
|
|
9
|
+
class OutcomePlanner
|
|
10
|
+
# @param snapshot [Models::AssignmentSnapshot]
|
|
11
|
+
# @param explicit_result [String, nil] --result flag
|
|
12
|
+
# @param message [String, nil] explicit cause
|
|
13
|
+
# @param step_evidence [String, nil] failed-step report content
|
|
14
|
+
# @param step_override [String, nil] explicit --step flag
|
|
15
|
+
# @return [Models::Outcome]
|
|
16
|
+
# @raise [Coworker::Error] on refused inputs (false success, missing cause)
|
|
17
|
+
def self.plan(snapshot, explicit_result: nil, message: nil, step_evidence: nil, step_override: nil)
|
|
18
|
+
if explicit_result && !%w[success error].include?(explicit_result)
|
|
19
|
+
raise Error, "invalid --result '#{explicit_result}': expected success or error"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
complete = snapshot.completion_verdict == :complete
|
|
23
|
+
result = explicit_result || (complete ? "success" : "error")
|
|
24
|
+
|
|
25
|
+
if result == "success" && !complete
|
|
26
|
+
raise Error,
|
|
27
|
+
"assignment '#{snapshot.id}' is not complete: #{snapshot.incomplete_reasons.join('; ')}; refusing to record work done"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if result == "error"
|
|
31
|
+
return error_outcome(snapshot, message, step_evidence, step_override)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
success_outcome(snapshot, step_override)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.success_outcome(snapshot, step_override)
|
|
38
|
+
step = if step_override
|
|
39
|
+
Models::Outcome::StepRef.new(number: step_override, name: step_name(snapshot, step_override))
|
|
40
|
+
else
|
|
41
|
+
last = snapshot.steps.last
|
|
42
|
+
Models::Outcome::StepRef.new(number: last&.number, name: last&.name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Models::Outcome.new(result: "success", verdict: :complete, step: step,
|
|
46
|
+
unfinished_steps: [],
|
|
47
|
+
evidence: {"progress" => snapshot.progress})
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.error_outcome(snapshot, message, step_evidence, step_override)
|
|
51
|
+
failed = snapshot.steps.reverse.find { |s| s.status == "failed" } ||
|
|
52
|
+
snapshot.steps.find { |s| s.status == "active" } ||
|
|
53
|
+
snapshot.steps.find { |s| s.status == "pending" } ||
|
|
54
|
+
snapshot.steps.last
|
|
55
|
+
step = if step_override
|
|
56
|
+
Models::Outcome::StepRef.new(number: step_override, name: step_name(snapshot, step_override))
|
|
57
|
+
else
|
|
58
|
+
Models::Outcome::StepRef.new(number: failed&.number, name: failed&.name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
cause = message.to_s.strip
|
|
62
|
+
cause = step_evidence.to_s.strip if cause.empty?
|
|
63
|
+
if cause.empty?
|
|
64
|
+
raise Error,
|
|
65
|
+
"error outcome for assignment '#{snapshot.id}' needs a cause: pass --message or provide a failed-step report"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
unfinished = snapshot.steps.reject(&:done?).map { |s| "#{s.number} #{s.name} (#{s.status})" }
|
|
69
|
+
Models::Outcome.new(result: "error", verdict: :incomplete, cause: cause, step: step,
|
|
70
|
+
unfinished_steps: unfinished)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.step_name(snapshot, number)
|
|
74
|
+
snapshot.steps.find { |s| s.number.to_s == number.to_s }&.name
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/git/worktree/atoms/task_id_extractor"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Coworker
|
|
7
|
+
module Atoms
|
|
8
|
+
# Pure disposition rules for the preparation decision procedure.
|
|
9
|
+
# Inputs are the structured hashes returned by the worktree owner
|
|
10
|
+
# (ace-git-worktree switch/create results); outputs are dispositions
|
|
11
|
+
# with reasons. No IO, no writes.
|
|
12
|
+
module PrepareDecision
|
|
13
|
+
# @param switch_result [Hash] WorktreeManager#switch result; with the
|
|
14
|
+
# real owner, a worktree whose directory is missing never resolves
|
|
15
|
+
# here (switch fails with a "directory does not exist" error).
|
|
16
|
+
# @param expected_task_id [String] id of the task being prepared
|
|
17
|
+
# @param stale_registration [Hash, nil] association-registered
|
|
18
|
+
# worktree for the expected task whose directory is missing on
|
|
19
|
+
# disk ({task_id:, worktree_path:, branch:}); recovered via prune
|
|
20
|
+
# + recreate instead of a plain create
|
|
21
|
+
# @return [Hash] {disposition:, reason:, worktree_path:, branch:, task_id:}
|
|
22
|
+
# disposition: :reuse | :conflict_mismatch | :stale_recovery | :miss
|
|
23
|
+
def self.from_switch(switch_result, expected_task_id:, stale_registration: nil)
|
|
24
|
+
if switch_result[:success]
|
|
25
|
+
task_id = switch_result[:task_id].to_s
|
|
26
|
+
base = {worktree_path: switch_result[:worktree_path],
|
|
27
|
+
branch: switch_result[:branch], task_id: task_id}
|
|
28
|
+
if expected_ids(expected_task_id).include?(task_id)
|
|
29
|
+
base.merge(disposition: :reuse, reason: "worktree already prepared for this task")
|
|
30
|
+
else
|
|
31
|
+
base.merge(disposition: :conflict_mismatch,
|
|
32
|
+
reason: "resolved worktree belongs to task '#{task_id}', not '#{expected_task_id}'")
|
|
33
|
+
end
|
|
34
|
+
elsif stale_registration
|
|
35
|
+
{disposition: :stale_recovery,
|
|
36
|
+
reason: "worktree metadata resolves to a missing directory: #{stale_registration[:worktree_path]}",
|
|
37
|
+
worktree_path: stale_registration[:worktree_path],
|
|
38
|
+
branch: stale_registration[:branch],
|
|
39
|
+
task_id: stale_registration[:task_id]}
|
|
40
|
+
else
|
|
41
|
+
{disposition: :miss, reason: switch_result[:error].to_s,
|
|
42
|
+
worktree_path: nil, branch: nil, task_id: nil}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ace-git-worktree stores the worktree association under the task's
|
|
47
|
+
# short id (e.g. "aaa") while ace-task resolves the full id
|
|
48
|
+
# ("8xh.t.aaa"); accept every recognized form of the expected id.
|
|
49
|
+
def self.expected_ids(expected_task_id)
|
|
50
|
+
full = expected_task_id.to_s
|
|
51
|
+
[full, Ace::Git::Worktree::Atoms::TaskIDExtractor.short_id(full),
|
|
52
|
+
Ace::Git::Worktree::Atoms::TaskIDExtractor.normalize(full)].compact.uniq
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param create_result [Hash] WorktreeManager#create_task result
|
|
56
|
+
# @param occupied_directory [Boolean] whether the task's expected
|
|
57
|
+
# worktree directory exists on disk (a foreign occupant when the
|
|
58
|
+
# create failed)
|
|
59
|
+
# @return [Hash] {disposition:, reason:, worktree_path:, branch:}
|
|
60
|
+
# disposition: :created | :recovered | :reuse | :conflict_mismatch | :error
|
|
61
|
+
def self.from_create(create_result, occupied_directory: false)
|
|
62
|
+
if create_result[:success]
|
|
63
|
+
disposition = create_result[:existing] ? :reuse : (create_result[:recovered] ? :recovered : :created)
|
|
64
|
+
return {disposition: disposition, reason: create_result[:message].to_s,
|
|
65
|
+
worktree_path: create_result[:worktree_path], branch: create_result[:branch]}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
disposition = occupied_directory ? :conflict_mismatch : :error
|
|
69
|
+
{disposition: disposition, reason: create_result[:error].to_s,
|
|
70
|
+
worktree_path: nil, branch: nil}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Coworker
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure start-prompt template rendering. Substitutes the known
|
|
7
|
+
# placeholders from a context hash; unknown placeholders stay literal.
|
|
8
|
+
# No IO.
|
|
9
|
+
class PromptTemplate
|
|
10
|
+
PLACEHOLDERS = %w[task assignment role overseer].freeze
|
|
11
|
+
PLACEHOLDER_PATTERN = /\{\{(#{PLACEHOLDERS.map { |p| Regexp.escape(p) }.join('|')})\}\}/.freeze
|
|
12
|
+
|
|
13
|
+
# @param template [String] preset start_prompt with {{placeholders}}
|
|
14
|
+
# @param context [Hash{String => String}] commission identity values
|
|
15
|
+
# @return [String] rendered prompt
|
|
16
|
+
def self.render(template, context)
|
|
17
|
+
# Single pass over PLACEHOLDER_PATTERN: only known placeholders
|
|
18
|
+
# match, and substituted values are never re-expanded (a value
|
|
19
|
+
# shaped like a later placeholder stays literal). Block form: the
|
|
20
|
+
# replacement is treated literally, so values containing
|
|
21
|
+
# backslashes or gsub escape sequences (\1, \0, \&) interpolate
|
|
22
|
+
# verbatim.
|
|
23
|
+
template.to_s.gsub(PLACEHOLDER_PATTERN) { context.fetch(Regexp.last_match(1), "").to_s }.strip
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Coworker
|
|
7
|
+
module Atoms
|
|
8
|
+
# Pure rendering of the durable task-folder record markdown.
|
|
9
|
+
class ReportRecordFormatter
|
|
10
|
+
# @param outcome [Models::Outcome]
|
|
11
|
+
# @param cfg [Models::Config]
|
|
12
|
+
# @param extras [Hash] event_id:, delivery:, record_path:
|
|
13
|
+
# @return [String] markdown document
|
|
14
|
+
def self.format(outcome, cfg, event_id: nil, delivery: nil, record_path: nil)
|
|
15
|
+
lines = []
|
|
16
|
+
lines << "---"
|
|
17
|
+
lines << "task: #{cfg.task}"
|
|
18
|
+
lines << "assignment: #{cfg.assignment}"
|
|
19
|
+
lines << "result: #{outcome.result}"
|
|
20
|
+
lines << "verdict: #{outcome.verdict}"
|
|
21
|
+
lines << "event_id: #{event_id}" if event_id
|
|
22
|
+
# Delivery can carry free-form failure detail ("failed: <cause>");
|
|
23
|
+
# serialize the value so the frontmatter stays parseable YAML.
|
|
24
|
+
lines << "delivery: #{delivery.to_s.inspect}" if delivery
|
|
25
|
+
lines << "updated_at: #{Time.now.utc.iso8601}"
|
|
26
|
+
lines << "---"
|
|
27
|
+
lines << ""
|
|
28
|
+
lines << "# #{outcome.success? ? "Work done" : "Assignment failed"} - #{cfg.task}/#{cfg.assignment}"
|
|
29
|
+
lines << ""
|
|
30
|
+
if outcome.success?
|
|
31
|
+
lines << "Assignment completed (#{outcome.evidence["progress"]})."
|
|
32
|
+
else
|
|
33
|
+
lines << "## Cause"
|
|
34
|
+
lines << ""
|
|
35
|
+
lines << outcome.cause
|
|
36
|
+
lines << ""
|
|
37
|
+
lines << "## Unfinished steps"
|
|
38
|
+
lines << ""
|
|
39
|
+
if outcome.unfinished_steps.empty?
|
|
40
|
+
lines << "- (none recorded)"
|
|
41
|
+
else
|
|
42
|
+
outcome.unfinished_steps.each { |step| lines << "- #{step}" }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
lines << ""
|
|
46
|
+
lines << "## Evidence"
|
|
47
|
+
lines << ""
|
|
48
|
+
lines << "- record: #{record_path}" if record_path
|
|
49
|
+
lines << "- reports: .ace-local/assign/#{cfg.assignment}/reports/"
|
|
50
|
+
lines << "- event: .ace-local/hitl/ (#{event_id})" if event_id
|
|
51
|
+
lines << ""
|
|
52
|
+
lines << "## Delivery"
|
|
53
|
+
lines << ""
|
|
54
|
+
lines << (delivery ? "- #{delivery}" : "- (not attempted)")
|
|
55
|
+
lines.join("\n") + "\n"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Coworker
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure rendering of a ResumePlan (table format). The brief is the
|
|
7
|
+
# guidance the next role or tool reads to continue safely.
|
|
8
|
+
class ResumeBriefFormatter
|
|
9
|
+
# @param plan [Models::ResumePlan]
|
|
10
|
+
# @param result [Organisms::ResumeOrchestrator::ResumeResult, nil]
|
|
11
|
+
# @return [String]
|
|
12
|
+
def self.format_table(plan, result = nil)
|
|
13
|
+
lines = ["Coworker resume"]
|
|
14
|
+
if plan.disposition == :report
|
|
15
|
+
lines << " assignment complete; report delivery missing"
|
|
16
|
+
if plan.redeliver_event_id
|
|
17
|
+
lines << " resend event: #{plan.redeliver_event_id} (same ID; retrying delivery never retries the assignment)"
|
|
18
|
+
end
|
|
19
|
+
lines << " #{result.message}" if result.message
|
|
20
|
+
return lines.join("\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if plan.disposition == :complete
|
|
24
|
+
lines << " assignment complete and delivered; nothing to resume"
|
|
25
|
+
return lines.join("\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
lines.concat(identity_lines(plan))
|
|
29
|
+
lines.concat(preserved_lines(plan))
|
|
30
|
+
lines.concat(remaining_lines(plan))
|
|
31
|
+
lines.concat(in_flight_lines(plan))
|
|
32
|
+
lines.concat(local_changes_lines(plan))
|
|
33
|
+
lines.concat(effect_caution_lines(plan))
|
|
34
|
+
lines.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.identity_lines(plan)
|
|
38
|
+
branch = plan.git["branch"] || "(detached)"
|
|
39
|
+
[" branch: #{branch}"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.preserved_lines(plan)
|
|
43
|
+
return [] if plan.preserved_steps.empty?
|
|
44
|
+
|
|
45
|
+
[" preserved steps:"] + plan.preserved_steps.map { |s| " done: #{s}" }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.remaining_lines(plan)
|
|
49
|
+
return [] if plan.remaining_steps.empty?
|
|
50
|
+
|
|
51
|
+
[" remaining work:"] + plan.remaining_steps.map { |s| " #{s}" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.in_flight_lines(plan)
|
|
55
|
+
return [] unless plan.in_flight
|
|
56
|
+
|
|
57
|
+
step = plan.in_flight
|
|
58
|
+
lines = [" in-flight step: #{step["number"]} #{step["name"]} (#{step["status"]})"]
|
|
59
|
+
lines << " recorded cause: #{plan.in_flight_cause}" if plan.in_flight_cause
|
|
60
|
+
lines << " step report: #{plan.step_report_path}" if plan.step_report_path
|
|
61
|
+
lines << " verify the effect from the current files/Git state and the step's own report before repeating it; do not reset local changes"
|
|
62
|
+
lines
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.local_changes_lines(plan)
|
|
66
|
+
dirty = plan.git["dirty_entries"] || []
|
|
67
|
+
return [] if dirty.empty?
|
|
68
|
+
|
|
69
|
+
[" local changes (preserved, never reset):"] + dirty.map { |entry| " #{entry}" }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.effect_caution_lines(plan)
|
|
73
|
+
return [] if plan.effect_caution_steps.empty?
|
|
74
|
+
|
|
75
|
+
[" effect caution: uncertain external effect (merge/publish/deploy/release/push)?"] +
|
|
76
|
+
plan.effect_caution_steps.map { |s| " step: #{s}" } +
|
|
77
|
+
[" check the actual state with existing tools (e.g. ace-git status, PR evidence) or report a blocker;",
|
|
78
|
+
" never replay blindly; same task, assignment and worktree"]
|
|
79
|
+
# never replay blindly; same task, assignment and worktree
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Coworker
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure derivation of the resume plan: ace-assign is the only step
|
|
7
|
+
# truth, the worktree git state is the only change truth, and the
|
|
8
|
+
# report helper state decides create-vs-resend. Never plans a new
|
|
9
|
+
# task/assignment/worktree and never plans resetting local changes.
|
|
10
|
+
class ResumePlanner
|
|
11
|
+
EFFECT_PATTERN = /merge|publish|deploy|release|push/i.freeze
|
|
12
|
+
|
|
13
|
+
# @param snapshot [Models::AssignmentSnapshot]
|
|
14
|
+
# @param report_state [Hash, nil] .ace-local/coworker/reports/<id>.json
|
|
15
|
+
# @param git_state [Hash] branch/ahead_behind/dirty_entries/last_commit
|
|
16
|
+
# @param step_cause [String, nil] recorded error of the in-flight step
|
|
17
|
+
# @param step_report_path [String, nil] report file of the in-flight step
|
|
18
|
+
# @return [Models::ResumePlan]
|
|
19
|
+
def self.plan(snapshot:, report_state: nil, git_state: {},
|
|
20
|
+
step_cause: nil, step_report_path: nil)
|
|
21
|
+
if snapshot.completion_verdict == :complete
|
|
22
|
+
return complete_plan(report_state, git_state)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
continue_plan(snapshot, report_state, git_state,
|
|
26
|
+
step_cause: step_cause, step_report_path: step_report_path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Only a DELIVERED SUCCESS report matches a completed assignment:
|
|
30
|
+
# a delivered error report describes an outcome the assignment has
|
|
31
|
+
# since left behind, so resume must still report the success.
|
|
32
|
+
def self.complete_plan(report_state, git_state)
|
|
33
|
+
delivered = report_state.is_a?(Hash) &&
|
|
34
|
+
report_state["result"] == "success" &&
|
|
35
|
+
report_state["delivery"].to_s.start_with?("delivered")
|
|
36
|
+
if delivered
|
|
37
|
+
Models::ResumePlan.new(disposition: :complete, git: git_state)
|
|
38
|
+
else
|
|
39
|
+
Models::ResumePlan.new(disposition: :report, git: git_state,
|
|
40
|
+
redeliver_event_id: report_state.is_a?(Hash) ? report_state["event_id"] : nil)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Single source of truth for the in-flight step: last active, else
|
|
45
|
+
# last failed, else first remaining. The orchestrator uses this to
|
|
46
|
+
# decide whose evidence to read; the planner renders the same step.
|
|
47
|
+
def self.in_flight_step(snapshot)
|
|
48
|
+
remaining = snapshot.steps.reject(&:done?)
|
|
49
|
+
remaining.reverse.find { |s| s.status == "active" } ||
|
|
50
|
+
remaining.reverse.find { |s| s.status == "failed" } ||
|
|
51
|
+
remaining.first
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.continue_plan(snapshot, report_state, git_state, step_cause:, step_report_path:)
|
|
55
|
+
preserved = snapshot.steps.select(&:done?).map { |s| "#{s.number} #{s.name}" }
|
|
56
|
+
remaining = snapshot.steps.reject(&:done?)
|
|
57
|
+
in_flight_step = in_flight_step(snapshot)
|
|
58
|
+
|
|
59
|
+
remaining_lines = remaining.map { |s| "#{s.number} #{s.name} (#{s.status})" }
|
|
60
|
+
caution = remaining_lines.select { |line| line.match?(EFFECT_PATTERN) }.uniq
|
|
61
|
+
|
|
62
|
+
Models::ResumePlan.new(disposition: :continue,
|
|
63
|
+
preserved_steps: preserved,
|
|
64
|
+
remaining_steps: remaining_lines,
|
|
65
|
+
in_flight: in_flight_step && {
|
|
66
|
+
"number" => in_flight_step.number, "name" => in_flight_step.name,
|
|
67
|
+
"status" => in_flight_step.status
|
|
68
|
+
},
|
|
69
|
+
in_flight_cause: step_cause,
|
|
70
|
+
step_report_path: step_report_path,
|
|
71
|
+
effect_caution_steps: caution,
|
|
72
|
+
git: git_state,
|
|
73
|
+
redeliver_event_id: report_state.is_a?(Hash) ? report_state["event_id"] : nil)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|