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,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Coworker
|
|
5
|
+
module Models
|
|
6
|
+
# Immutable preparation result for `ace-coworker prepare`: the accepted
|
|
7
|
+
# commission (task + roles), the resolved worktree (path/branch/
|
|
8
|
+
# disposition) or the conflict that blocked it, plus the executor
|
|
9
|
+
# evidence collected from the worktree. No IO.
|
|
10
|
+
class Preparation
|
|
11
|
+
attr_reader :task, :task_path, :roles, :worktree, :worktree_assignments,
|
|
12
|
+
:active_executor, :conflict, :assignments, :start_configs, :report, :dry_run
|
|
13
|
+
|
|
14
|
+
# worktree: {"path" =>, "branch" =>, "disposition" =>} or nil
|
|
15
|
+
# conflict: {"kind" =>, "detail" =>, "hitl_event" =>, "delivered" =>}
|
|
16
|
+
# or nil; a non-nil conflict replaces the worktree in #to_h.
|
|
17
|
+
# assignments: ordered role-sequence assignments composed by the
|
|
18
|
+
# planner (L4.2): {"role", "position", "of", "assignment",
|
|
19
|
+
# "disposition", "steps", "outcome"} per role, stored by
|
|
20
|
+
# ace-assign in the worktree.
|
|
21
|
+
# start_configs: per-role leg configs written by the planner
|
|
22
|
+
# (L4.3): {"role", "assignment", "path", "disposition"} per leg,
|
|
23
|
+
# at .ace-local/coworker/prepare/<role>.yml in the worktree.
|
|
24
|
+
# report: the planner's readiness report to the overseer via
|
|
25
|
+
# ace-hitl (L4.4) on success: {"kind" => "readiness",
|
|
26
|
+
# "event" =>, "delivered" =>} or {"kind" =>, "error" =>} when
|
|
27
|
+
# the report could not be created; nil when no report applies
|
|
28
|
+
# (dry-run) or a conflict escalation replaced it.
|
|
29
|
+
def initialize(task:, task_path:, roles:, worktree: nil,
|
|
30
|
+
worktree_assignments: [], active_executor: nil, conflict: nil,
|
|
31
|
+
assignments: [], start_configs: [], report: nil, dry_run: false)
|
|
32
|
+
@task = task
|
|
33
|
+
@task_path = task_path
|
|
34
|
+
@roles = roles.map(&:freeze).freeze
|
|
35
|
+
@worktree = worktree
|
|
36
|
+
@worktree_assignments = worktree_assignments.map(&:freeze).freeze
|
|
37
|
+
@active_executor = active_executor
|
|
38
|
+
@conflict = conflict
|
|
39
|
+
@assignments = assignments.map(&:freeze).freeze
|
|
40
|
+
@start_configs = start_configs.map(&:freeze).freeze
|
|
41
|
+
@report = report ? report.dup.freeze : nil
|
|
42
|
+
@dry_run = dry_run
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def conflict?
|
|
46
|
+
!conflict.nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_h
|
|
50
|
+
payload = {
|
|
51
|
+
"task" => task,
|
|
52
|
+
"task_path" => task_path,
|
|
53
|
+
"roles" => roles,
|
|
54
|
+
"assignments" => assignments,
|
|
55
|
+
"start_configs" => start_configs,
|
|
56
|
+
"report" => report,
|
|
57
|
+
"active_executor" => active_executor,
|
|
58
|
+
"worktree_assignments" => worktree_assignments,
|
|
59
|
+
"conflict" => conflict
|
|
60
|
+
}
|
|
61
|
+
payload["worktree"] = worktree unless conflict?
|
|
62
|
+
payload
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Coworker
|
|
5
|
+
module Models
|
|
6
|
+
# Immutable resume plan derived from the assignment snapshot, the
|
|
7
|
+
# worktree git state, and the report helper state. No IO.
|
|
8
|
+
class ResumePlan
|
|
9
|
+
attr_reader :disposition, :preserved_steps, :remaining_steps,
|
|
10
|
+
:in_flight, :in_flight_cause, :step_report_path,
|
|
11
|
+
:effect_caution_steps, :git, :redeliver_event_id
|
|
12
|
+
|
|
13
|
+
def initialize(disposition:, preserved_steps: [], remaining_steps: [],
|
|
14
|
+
in_flight: nil, in_flight_cause: nil, step_report_path: nil,
|
|
15
|
+
effect_caution_steps: [], git: {}, redeliver_event_id: nil)
|
|
16
|
+
@disposition = disposition
|
|
17
|
+
@preserved_steps = preserved_steps.map(&:freeze).freeze
|
|
18
|
+
@remaining_steps = remaining_steps.map(&:freeze).freeze
|
|
19
|
+
@in_flight = in_flight
|
|
20
|
+
@in_flight_cause = in_flight_cause
|
|
21
|
+
@step_report_path = step_report_path
|
|
22
|
+
@effect_caution_steps = effect_caution_steps.map(&:freeze).freeze
|
|
23
|
+
@git = git.freeze
|
|
24
|
+
@redeliver_event_id = redeliver_event_id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def continue?
|
|
28
|
+
disposition == :continue
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{
|
|
33
|
+
"disposition" => disposition.to_s,
|
|
34
|
+
"preserved_steps" => preserved_steps,
|
|
35
|
+
"remaining_steps" => remaining_steps,
|
|
36
|
+
"in_flight" => in_flight,
|
|
37
|
+
"in_flight_cause" => in_flight_cause,
|
|
38
|
+
"step_report_path" => step_report_path,
|
|
39
|
+
"effect_caution_steps" => effect_caution_steps,
|
|
40
|
+
"git" => git,
|
|
41
|
+
"redeliver_event_id" => redeliver_event_id
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Coworker
|
|
7
|
+
module Molecules
|
|
8
|
+
# Reads the assignment's own metadata (.ace-local/assign/<id>/assignment.yaml)
|
|
9
|
+
# for the task↔assignment match check. Read-only: ace-assign owns the files.
|
|
10
|
+
class AssignmentMetadata
|
|
11
|
+
ASSIGN_DIR = File.join(".ace-local", "assign")
|
|
12
|
+
|
|
13
|
+
def initialize(project_root:)
|
|
14
|
+
@project_root = project_root
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def assignment_dir(assignment_id)
|
|
18
|
+
id = assignment_id.to_s
|
|
19
|
+
unless id.match?(/\A[\w.-]+\z/) && !id.match?(/\A\.+\z/)
|
|
20
|
+
raise Error, "invalid assignment id '#{id}': expected letters, digits, dot, dash, or underscore"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
File.join(@project_root, ASSIGN_DIR, id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def assignment_file(assignment_id)
|
|
27
|
+
File.join(assignment_dir(assignment_id), "assignment.yaml")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [String, nil] the task_id recorded by ace-assign
|
|
31
|
+
# @raise [Error] when the assignment does not exist under .ace-local/assign/
|
|
32
|
+
def task_id!(assignment_id)
|
|
33
|
+
path = assignment_file(assignment_id)
|
|
34
|
+
unless File.file?(path)
|
|
35
|
+
raise Error, "assignment '#{assignment_id}' not found under #{File.join(ASSIGN_DIR, assignment_id)}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
data = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true) || {}
|
|
39
|
+
data["task_id"].to_s.strip
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @raise [Error] when the assignment belongs to a different task
|
|
43
|
+
def verify_task_match!(assignment_id, task_ref)
|
|
44
|
+
recorded = task_id!(assignment_id)
|
|
45
|
+
if recorded.empty?
|
|
46
|
+
raise Error, "assignment '#{assignment_id}' has no task_id; cannot match task '#{task_ref}'"
|
|
47
|
+
end
|
|
48
|
+
if recorded != task_ref.to_s
|
|
49
|
+
raise Error, "assignment '#{assignment_id}' belongs to task '#{recorded}', not '#{task_ref}'"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
recorded
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Coworker
|
|
8
|
+
module Molecules
|
|
9
|
+
# Probes assignment truth via `ace-assign status --assignment <id> --format json`
|
|
10
|
+
# with an injectable runner (ADR-031: argv array + explicit cwd).
|
|
11
|
+
class AssignmentProbe
|
|
12
|
+
DEFAULT_RUNNER = ->(argv, cwd) { Open3.capture3(*argv, chdir: cwd) }
|
|
13
|
+
|
|
14
|
+
def initialize(project_root:, runner: DEFAULT_RUNNER)
|
|
15
|
+
@project_root = project_root
|
|
16
|
+
@runner = runner
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Models::AssignmentSnapshot]
|
|
20
|
+
# @raise [Error] when the assignment is unknown or status cannot be read
|
|
21
|
+
def snapshot(assignment_id)
|
|
22
|
+
argv = [assign_binary, "status", "--assignment", assignment_id.to_s, "--format", "json"]
|
|
23
|
+
begin
|
|
24
|
+
out, err, status = run(argv)
|
|
25
|
+
rescue Errno::ENOENT
|
|
26
|
+
raise Error, "required CLI '#{assign_binary}' not found; install the ace-assign gem"
|
|
27
|
+
end
|
|
28
|
+
unless status.success?
|
|
29
|
+
message = err.to_s.strip.empty? ? out.to_s.strip : err.to_s.strip
|
|
30
|
+
raise Error, "cannot read assignment '#{assignment_id}': #{message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
begin
|
|
34
|
+
payload = JSON.parse(out)
|
|
35
|
+
rescue JSON::ParserError => e
|
|
36
|
+
raise Error, "cannot parse ace-assign status for '#{assignment_id}': #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Models::AssignmentSnapshot.from_status_json(payload)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def run(argv)
|
|
45
|
+
@runner.call(argv, @project_root)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Prefer the repo binstub when present (agent shells do not activate
|
|
49
|
+
# mise, so bare ace-* may resolve to stale installed gems).
|
|
50
|
+
def assign_binary
|
|
51
|
+
binstub = File.join(@project_root, "bin", "ace-assign")
|
|
52
|
+
File.executable?(binstub) ? binstub : "ace-assign"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Coworker
|
|
7
|
+
module Molecules
|
|
8
|
+
# Loads and validates .ace-local/coworker/config.yml.
|
|
9
|
+
#
|
|
10
|
+
# Missing or invalid input produces specific errors; the loader never
|
|
11
|
+
# guesses a substitute commission.
|
|
12
|
+
class ConfigLoader
|
|
13
|
+
DEFAULT_CONFIG_PATH = File.join(".ace-local", "coworker", "config.yml")
|
|
14
|
+
|
|
15
|
+
# @param project_root [String] absolute project root
|
|
16
|
+
# @param path [String, nil] explicit config path (absolute or root-relative)
|
|
17
|
+
# @param defaults [Hash] cascade defaults (config_path override)
|
|
18
|
+
def initialize(project_root:, defaults: {})
|
|
19
|
+
@project_root = project_root
|
|
20
|
+
@defaults = defaults || {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def config_path
|
|
24
|
+
configured = @defaults["config_path"].to_s.strip
|
|
25
|
+
relative = configured.empty? ? DEFAULT_CONFIG_PATH : configured
|
|
26
|
+
File.expand_path(relative, @project_root)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Models::Config]
|
|
30
|
+
def load(path: nil)
|
|
31
|
+
target = path ? File.expand_path(path, @project_root) : config_path
|
|
32
|
+
raise Error, "not ready: #{relativize(target)} not found" unless File.file?(target)
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
raw = YAML.safe_load_file(target, permitted_classes: [Date], aliases: true)
|
|
36
|
+
rescue Psych::SyntaxError => e
|
|
37
|
+
raise Error, "invalid config #{relativize(target)}: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
raw = {} if raw.nil?
|
|
40
|
+
unless raw.is_a?(Hash)
|
|
41
|
+
raise Error, "invalid config #{relativize(target)}: expected a mapping, got #{raw.class}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
errors = Atoms::ConfigSchema.errors(raw)
|
|
45
|
+
unless errors.empty?
|
|
46
|
+
raise Error, "invalid config #{relativize(target)}: #{errors.join('; ')}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
build_config(Atoms::ConfigSchema.normalize(raw), target)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ready?(path: nil)
|
|
53
|
+
target = path ? File.expand_path(path, @project_root) : config_path
|
|
54
|
+
File.file?(target)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Lenient overseer lookup for escalation fallbacks: reads the raw
|
|
58
|
+
# file without full-schema validation so a partial (planner-side)
|
|
59
|
+
# config can still name its overseer. Returns nil when the field is
|
|
60
|
+
# absent or the file cannot be read.
|
|
61
|
+
#
|
|
62
|
+
# @return [String, nil]
|
|
63
|
+
def overseer(path: nil)
|
|
64
|
+
target = path ? File.expand_path(path, @project_root) : config_path
|
|
65
|
+
return nil unless File.file?(target)
|
|
66
|
+
|
|
67
|
+
raw = YAML.safe_load_file(target, permitted_classes: [Date], aliases: true)
|
|
68
|
+
return nil unless raw.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
overseer = raw["overseer"].to_s
|
|
71
|
+
overseer.strip.empty? ? nil : overseer
|
|
72
|
+
rescue
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def build_config(normalized, target)
|
|
79
|
+
Models::Config.new(
|
|
80
|
+
role: normalized["role"],
|
|
81
|
+
instructions: normalized["instructions"],
|
|
82
|
+
start_prompt: start_prompt(normalized),
|
|
83
|
+
start_prompt_file: normalized["start_prompt_file"].to_s.empty? ? nil : normalized["start_prompt_file"],
|
|
84
|
+
task: normalized["task"],
|
|
85
|
+
assignment: normalized["assignment"],
|
|
86
|
+
overseer: normalized["overseer"],
|
|
87
|
+
reporting_channel: normalized.dig("reporting", "channel"),
|
|
88
|
+
on_failure: normalized.dig("errors", "on_failure"),
|
|
89
|
+
source_path: target
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# start_prompt_file content is the prompt; the file must exist, be
|
|
94
|
+
# readable, and stay inside the project root (same containment rule
|
|
95
|
+
# as InstructionResolver: paths never escape the worktree).
|
|
96
|
+
def start_prompt(normalized)
|
|
97
|
+
literal = normalized["start_prompt"]
|
|
98
|
+
return literal unless literal.to_s.empty?
|
|
99
|
+
|
|
100
|
+
file = normalized["start_prompt_file"].to_s
|
|
101
|
+
path = File.expand_path(file, @project_root)
|
|
102
|
+
unless path.start_with?("#{@project_root}/")
|
|
103
|
+
raise Error, "start_prompt_file '#{file}' escapes the project root"
|
|
104
|
+
end
|
|
105
|
+
raise Error, "start_prompt_file '#{file}' not found in this worktree" unless File.file?(path)
|
|
106
|
+
|
|
107
|
+
content = File.read(path).strip
|
|
108
|
+
raise Error, "start_prompt_file '#{file}' is empty" if content.empty?
|
|
109
|
+
|
|
110
|
+
content
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def relativize(path)
|
|
114
|
+
path.start_with?(@project_root) ? path.delete_prefix("#{@project_root}/") : path
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Coworker
|
|
7
|
+
module Molecules
|
|
8
|
+
# Read-only worktree truth for resume: current branch, ahead/behind,
|
|
9
|
+
# dirty entries, last commit. Never issues mutating git subcommands;
|
|
10
|
+
# the worktree state IS the checkpoint (no journal, ADR-034).
|
|
11
|
+
class GitStateProbe
|
|
12
|
+
DEFAULT_RUNNER = ->(argv, cwd) { Open3.capture3(*argv, chdir: cwd) }
|
|
13
|
+
|
|
14
|
+
def initialize(project_root:, runner: DEFAULT_RUNNER)
|
|
15
|
+
@project_root = project_root
|
|
16
|
+
@runner = runner
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Hash] frozen {branch, ahead_behind, dirty_entries, last_commit}
|
|
20
|
+
# @raise [Error] when the worktree is not a git repository
|
|
21
|
+
def state
|
|
22
|
+
status_out, status_err, status = run(%w[status --porcelain=v1 -b])
|
|
23
|
+
unless status.success?
|
|
24
|
+
raise Error, "cannot read worktree state: #{status_err.to_s.strip.empty? ? "git status failed" : status_err.strip}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
branch_line = status_out.lines.first.to_s.strip
|
|
28
|
+
branch, ahead_behind = parse_branch(branch_line)
|
|
29
|
+
dirty = status_out.lines.drop(1).map(&:strip).reject(&:empty?)
|
|
30
|
+
|
|
31
|
+
log_out, _log_err, log_status = run(%w[log -1 --pretty=format:%h|%s])
|
|
32
|
+
last_commit = log_status.success? ? log_out.to_s.strip : nil
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
"branch" => branch,
|
|
36
|
+
"ahead_behind" => ahead_behind,
|
|
37
|
+
"dirty_entries" => dirty,
|
|
38
|
+
"last_commit" => last_commit
|
|
39
|
+
}.freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parse_branch(line)
|
|
45
|
+
# "## main...origin/main [ahead 1, behind 2]"
|
|
46
|
+
header = line.sub(/\A##\s+/, "")
|
|
47
|
+
return [nil, nil] if header.empty? || header.include?("no branch")
|
|
48
|
+
header = header.sub(/\ANo commits yet on /, "")
|
|
49
|
+
|
|
50
|
+
branch, tracking = header.split("...", 2)
|
|
51
|
+
ahead_behind = tracking.to_s[/\[(.+)\]/, 1]
|
|
52
|
+
[branch, ahead_behind]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def run(args)
|
|
56
|
+
@runner.call(["git", *args], @project_root)
|
|
57
|
+
rescue Errno::ENOENT
|
|
58
|
+
raise Error, "cannot read worktree state: git binary not found"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Coworker
|
|
7
|
+
module Molecules
|
|
8
|
+
# Sends the coworker's report through the ace-hitl CLI (subprocess
|
|
9
|
+
# argv contract, ADR-031; injectable runner). ace-hitl owns its files;
|
|
10
|
+
# this molecule never writes HITL state directly.
|
|
11
|
+
class HitlReporter
|
|
12
|
+
DEFAULT_RUNNER = ->(argv, cwd) { Open3.capture3(*argv, chdir: cwd) }
|
|
13
|
+
|
|
14
|
+
Failure = Struct.new(:stage, :message, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
def initialize(project_root:, runner: DEFAULT_RUNNER)
|
|
17
|
+
@project_root = project_root
|
|
18
|
+
@runner = runner
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param outcome [Models::Outcome]
|
|
22
|
+
# @param cfg [Models::Config]
|
|
23
|
+
# @return [String, Failure] created event id, or Failure when the
|
|
24
|
+
# ace-hitl create command fails
|
|
25
|
+
# @raise [Error] when ace-hitl is not available at all
|
|
26
|
+
def create(outcome, cfg)
|
|
27
|
+
argv = [hitl_binary, "create", title(outcome, cfg)]
|
|
28
|
+
argv += ["--type", "report", "--result", outcome.result]
|
|
29
|
+
body_lines(outcome, cfg).each { |line| argv += ["--body", line] }
|
|
30
|
+
argv += ["--task", cfg.task, "--assignment", cfg.assignment]
|
|
31
|
+
argv += ["--step", outcome.step.number.to_s] if outcome.step&.number
|
|
32
|
+
argv += ["--step-name", outcome.step.name.to_s] if outcome.step&.name && !outcome.step.name.to_s.empty?
|
|
33
|
+
argv += ["--to", cfg.overseer] unless cfg.overseer.to_s.strip.empty?
|
|
34
|
+
|
|
35
|
+
create_event(argv)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Creates a report-type event (no reply expected): the planner's
|
|
39
|
+
# readiness report (L4.4) uses this. A report requires a result
|
|
40
|
+
# ("success"/"error") per the ace-hitl CLI contract.
|
|
41
|
+
#
|
|
42
|
+
# @param title [String] event title
|
|
43
|
+
# @param body_lines [Array<String>] report body lines
|
|
44
|
+
# @param task [String] task reference
|
|
45
|
+
# @param result [String] "success" or "error"
|
|
46
|
+
# @param to [String, nil] recipient address
|
|
47
|
+
# @return [String, Failure] created event id, or Failure when the
|
|
48
|
+
# ace-hitl create command fails
|
|
49
|
+
# @raise [Error] when ace-hitl is not available at all
|
|
50
|
+
def create_report(title:, body_lines:, task:, result:, to: nil)
|
|
51
|
+
argv = [hitl_binary, "create", title]
|
|
52
|
+
argv += ["--type", "report", "--result", result]
|
|
53
|
+
body_lines.each { |line| argv += ["--body", line] }
|
|
54
|
+
argv += ["--task", task]
|
|
55
|
+
argv += ["--to", to] unless to.to_s.strip.empty?
|
|
56
|
+
|
|
57
|
+
create_event(argv)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Creates a decision-type question event for a preparation conflict.
|
|
61
|
+
#
|
|
62
|
+
# @param title [String] event title
|
|
63
|
+
# @param question_lines [Array<String>] question body lines
|
|
64
|
+
# @param task [String] task reference
|
|
65
|
+
# @param to [String, nil] escalation recipient
|
|
66
|
+
# @return [String, Failure] created event id, or Failure when the
|
|
67
|
+
# ace-hitl create command fails
|
|
68
|
+
# @raise [Error] when ace-hitl is not available at all
|
|
69
|
+
def create_conflict(title:, question_lines:, task:, to: nil)
|
|
70
|
+
argv = [hitl_binary, "create", title]
|
|
71
|
+
argv += ["--type", "question", "--kind", "decision"]
|
|
72
|
+
question_lines.each { |line| argv += ["--question", line] }
|
|
73
|
+
argv += ["--task", task]
|
|
74
|
+
argv += ["--to", to] unless to.to_s.strip.empty?
|
|
75
|
+
|
|
76
|
+
create_event(argv)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [true, Failure]
|
|
80
|
+
def send_report(event_id)
|
|
81
|
+
out, err, status = run([hitl_binary, "send", event_id])
|
|
82
|
+
return true if status.success?
|
|
83
|
+
|
|
84
|
+
message = err.to_s.strip.empty? ? out.to_s.strip : err.to_s.strip
|
|
85
|
+
Failure.new(stage: "send", message: message)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Boolean] whether ace-hitl knows the event. Delegates to
|
|
89
|
+
# the ace-hitl CLI so its own configured root_dir (possibly not
|
|
90
|
+
# .ace-local/hitl) decides; this molecule never reconstructs HITL
|
|
91
|
+
# storage paths.
|
|
92
|
+
def event_exists?(event_id)
|
|
93
|
+
_out, _err, status = run([hitl_binary, "show", event_id, "--path"])
|
|
94
|
+
status.success?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def create_event(argv)
|
|
100
|
+
out, err, status = run(argv)
|
|
101
|
+
unless status.success?
|
|
102
|
+
message = err.to_s.strip.empty? ? out.to_s.strip : err.to_s.strip
|
|
103
|
+
return Failure.new(stage: "create", message: message)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
out.to_s.match(/HITL event created: ([0-9a-z]{6})/) do |match|
|
|
107
|
+
return match[1]
|
|
108
|
+
end
|
|
109
|
+
Failure.new(stage: "create", message: "could not parse event id from ace-hitl output: #{out.to_s.strip}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def run(argv)
|
|
113
|
+
@runner.call(argv, @project_root)
|
|
114
|
+
rescue Errno::ENOENT
|
|
115
|
+
raise Error, "required CLI '#{hitl_binary}' not found; install the ace-hitl gem"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Prefer the repo binstub when present (agent shells do not activate
|
|
119
|
+
# mise, so bare ace-* may resolve to stale installed gems).
|
|
120
|
+
def hitl_binary
|
|
121
|
+
binstub = File.join(@project_root, "bin", "ace-hitl")
|
|
122
|
+
File.executable?(binstub) ? binstub : "ace-hitl"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def title(outcome, cfg)
|
|
126
|
+
outcome.success? ? "Work done - #{cfg.task}/#{cfg.assignment}" : "Assignment failed - #{cfg.task}/#{cfg.assignment}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def body_lines(outcome, cfg)
|
|
130
|
+
lines = []
|
|
131
|
+
if outcome.success?
|
|
132
|
+
lines << "Assignment #{cfg.assignment} completed (#{outcome.evidence["progress"]})."
|
|
133
|
+
else
|
|
134
|
+
step = outcome.step
|
|
135
|
+
lines << "failed at step #{step&.number} #{step&.name}: #{outcome.cause}"
|
|
136
|
+
outcome.unfinished_steps.each { |unfinished| lines << "unfinished: #{unfinished}" }
|
|
137
|
+
end
|
|
138
|
+
lines << "record: .ace-tasks/<task>/coworker/#{cfg.assignment}.md"
|
|
139
|
+
lines << "evidence: .ace-local/assign/#{cfg.assignment}/reports/"
|
|
140
|
+
lines
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Coworker
|
|
7
|
+
module Molecules
|
|
8
|
+
# Resolves the role instruction source: a file path relative to the
|
|
9
|
+
# project root, or a protocol URI (wfi://, prompt:// tmpl://) resolved
|
|
10
|
+
# through ace-nav.
|
|
11
|
+
class InstructionResolver
|
|
12
|
+
DEFAULT_NAV_RUNNER = ->(argv, cwd) { Open3.capture3(*argv, chdir: cwd) }
|
|
13
|
+
|
|
14
|
+
# The ace-nav CLI itself is missing (ENOENT on exec): the resolver
|
|
15
|
+
# never ran, so no verdict exists. Prepare treats this distinctly
|
|
16
|
+
# from a resolver that RUNS and fails — the former is a
|
|
17
|
+
# non-blocking unknown, the latter a never-ready gap.
|
|
18
|
+
class NavUnavailableError < Error; end
|
|
19
|
+
|
|
20
|
+
def initialize(project_root:, nav_runner: DEFAULT_NAV_RUNNER)
|
|
21
|
+
@project_root = project_root
|
|
22
|
+
@nav_runner = nav_runner
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [String] resolved absolute path of the instruction source
|
|
26
|
+
# @raise [Error] when the source cannot be resolved
|
|
27
|
+
def resolve!(instructions)
|
|
28
|
+
value = instructions.to_s.strip
|
|
29
|
+
if Atoms::ConfigSchema.protocol?(value)
|
|
30
|
+
resolve_protocol(value)
|
|
31
|
+
else
|
|
32
|
+
resolve_file(value)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def resolve_protocol(uri)
|
|
39
|
+
begin
|
|
40
|
+
out, err, status = @nav_runner.call([nav_binary, "resolve", uri], @project_root)
|
|
41
|
+
rescue Errno::ENOENT
|
|
42
|
+
raise NavUnavailableError,
|
|
43
|
+
"protocol instructions require the ace-nav CLI ('#{nav_binary}' not found)"
|
|
44
|
+
end
|
|
45
|
+
unless status.success?
|
|
46
|
+
message = err.to_s.strip.empty? ? out.to_s.strip : err.to_s.strip
|
|
47
|
+
raise Error, "cannot resolve instruction source '#{uri}': #{message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
path = out.to_s.lines.map(&:strip).reject(&:empty?).first.to_s
|
|
51
|
+
raise Error, "cannot resolve instruction source '#{uri}': ace-nav returned no path" if path.empty?
|
|
52
|
+
raise Error, "instruction source '#{uri}' resolved to missing file '#{path}'" unless File.file?(path)
|
|
53
|
+
|
|
54
|
+
path
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_file(relative)
|
|
58
|
+
candidate = File.expand_path(relative, @project_root)
|
|
59
|
+
# Containment first: an outside-root path must never depend on
|
|
60
|
+
# whether the outside file exists to pick its error message.
|
|
61
|
+
unless candidate.start_with?("#{@project_root}/")
|
|
62
|
+
raise Error, "instruction file '#{relative}' escapes the project root"
|
|
63
|
+
end
|
|
64
|
+
unless File.file?(candidate)
|
|
65
|
+
raise Error, "instruction file '#{relative}' not found in this worktree"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
candidate
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Same binary choice as start; a privileged (root) run never
|
|
72
|
+
# executes the repository-controlled binstub — PATH only
|
|
73
|
+
# (trust-posture parity with ace-lab Verify#nav_binary).
|
|
74
|
+
def nav_binary
|
|
75
|
+
binstub = File.join(@project_root, "bin", "ace-nav")
|
|
76
|
+
use_binstub = !Process.uid.zero? && File.executable?(binstub)
|
|
77
|
+
use_binstub ? binstub : "ace-nav"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Coworker
|
|
7
|
+
module Molecules
|
|
8
|
+
# Resolves the current project root (Git worktree top) with an
|
|
9
|
+
# injectable runner (ADR-031 argv contract).
|
|
10
|
+
class ProjectRoot
|
|
11
|
+
DEFAULT_GIT_RUNNER = ->(*args) { Open3.capture3("git", *args) }
|
|
12
|
+
|
|
13
|
+
def initialize(git_runner: DEFAULT_GIT_RUNNER)
|
|
14
|
+
@git_runner = git_runner
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] absolute project root; falls back to Dir.pwd
|
|
18
|
+
def resolve(from: Dir.pwd)
|
|
19
|
+
out, _err, _status = @git_runner.call("-C", from, "rev-parse", "--show-toplevel")
|
|
20
|
+
root = out.to_s.strip
|
|
21
|
+
root.empty? ? File.expand_path(from) : root
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|