ace-coworker 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/coworker/config.yml +6 -0
  3. data/.ace-defaults/coworker/roles/integrator.yml +15 -0
  4. data/.ace-defaults/coworker/roles/reviewer.yml +15 -0
  5. data/.ace-defaults/coworker/roles/spec.yml +14 -0
  6. data/.ace-defaults/coworker/roles/worker.yml +13 -0
  7. data/.ace-defaults/coworker/sequences/integrator.yml +20 -0
  8. data/.ace-defaults/coworker/sequences/reviewer.yml +20 -0
  9. data/.ace-defaults/coworker/sequences/spec.yml +16 -0
  10. data/.ace-defaults/coworker/sequences/worker.yml +21 -0
  11. data/.ace-defaults/nav/protocols/wfi-sources/ace-coworker.yml +19 -0
  12. data/CHANGELOG.md +255 -0
  13. data/README.md +98 -0
  14. data/Rakefile +13 -0
  15. data/docs/usage.md +564 -0
  16. data/exe/ace-coworker +16 -0
  17. data/handbook/workflow-instructions/coworker/integrator.wf.md +162 -0
  18. data/handbook/workflow-instructions/coworker/reviewer.wf.md +211 -0
  19. data/handbook/workflow-instructions/coworker/spec.wf.md +100 -0
  20. data/handbook/workflow-instructions/coworker/worker.wf.md +103 -0
  21. data/lib/ace/coworker/atoms/active_executor_rule.rb +71 -0
  22. data/lib/ace/coworker/atoms/brief_formatter.rb +33 -0
  23. data/lib/ace/coworker/atoms/config_schema.rb +121 -0
  24. data/lib/ace/coworker/atoms/integration_gate.rb +92 -0
  25. data/lib/ace/coworker/atoms/outcome_planner.rb +79 -0
  26. data/lib/ace/coworker/atoms/prepare_decision.rb +75 -0
  27. data/lib/ace/coworker/atoms/prompt_template.rb +28 -0
  28. data/lib/ace/coworker/atoms/report_record_formatter.rb +60 -0
  29. data/lib/ace/coworker/atoms/resume_brief_formatter.rb +84 -0
  30. data/lib/ace/coworker/atoms/resume_planner.rb +78 -0
  31. data/lib/ace/coworker/atoms/role_preset_schema.rb +91 -0
  32. data/lib/ace/coworker/atoms/round_record_schema.rb +62 -0
  33. data/lib/ace/coworker/atoms/round_rule.rb +105 -0
  34. data/lib/ace/coworker/cli/commands/commission.rb +100 -0
  35. data/lib/ace/coworker/cli/commands/gate.rb +91 -0
  36. data/lib/ace/coworker/cli/commands/prepare.rb +138 -0
  37. data/lib/ace/coworker/cli/commands/report.rb +79 -0
  38. data/lib/ace/coworker/cli/commands/resume.rb +57 -0
  39. data/lib/ace/coworker/cli/commands/roles.rb +60 -0
  40. data/lib/ace/coworker/cli/commands/rounds.rb +99 -0
  41. data/lib/ace/coworker/cli/commands/start.rb +39 -0
  42. data/lib/ace/coworker/cli/commands/status.rb +95 -0
  43. data/lib/ace/coworker/cli.rb +83 -0
  44. data/lib/ace/coworker/errors.rb +10 -0
  45. data/lib/ace/coworker/models/assignment_snapshot.rb +65 -0
  46. data/lib/ace/coworker/models/brief.rb +38 -0
  47. data/lib/ace/coworker/models/config.rb +54 -0
  48. data/lib/ace/coworker/models/outcome.rb +39 -0
  49. data/lib/ace/coworker/models/preparation.rb +67 -0
  50. data/lib/ace/coworker/models/resume_plan.rb +47 -0
  51. data/lib/ace/coworker/molecules/assignment_metadata.rb +57 -0
  52. data/lib/ace/coworker/molecules/assignment_probe.rb +57 -0
  53. data/lib/ace/coworker/molecules/config_loader.rb +119 -0
  54. data/lib/ace/coworker/molecules/git_state_probe.rb +63 -0
  55. data/lib/ace/coworker/molecules/hitl_reporter.rb +145 -0
  56. data/lib/ace/coworker/molecules/instruction_resolver.rb +82 -0
  57. data/lib/ace/coworker/molecules/project_root.rb +26 -0
  58. data/lib/ace/coworker/molecules/report_state_store.rb +53 -0
  59. data/lib/ace/coworker/molecules/review_round_store.rb +115 -0
  60. data/lib/ace/coworker/molecules/role_preset_loader.rb +95 -0
  61. data/lib/ace/coworker/molecules/role_sequence_composer.rb +245 -0
  62. data/lib/ace/coworker/molecules/step_evidence_reader.rb +60 -0
  63. data/lib/ace/coworker/molecules/step_report_reader.rb +39 -0
  64. data/lib/ace/coworker/molecules/task_probe.rb +40 -0
  65. data/lib/ace/coworker/molecules/task_record_writer.rb +44 -0
  66. data/lib/ace/coworker/molecules/worktree_executor_probe.rb +139 -0
  67. data/lib/ace/coworker/molecules/worktree_provision_probe.rb +137 -0
  68. data/lib/ace/coworker/organisms/commission_orchestrator.rb +148 -0
  69. data/lib/ace/coworker/organisms/prepare_orchestrator.rb +374 -0
  70. data/lib/ace/coworker/organisms/report_orchestrator.rb +192 -0
  71. data/lib/ace/coworker/organisms/resume_orchestrator.rb +91 -0
  72. data/lib/ace/coworker/organisms/start_config_writer.rb +127 -0
  73. data/lib/ace/coworker/organisms/start_orchestrator.rb +45 -0
  74. data/lib/ace/coworker/organisms/status_orchestrator.rb +62 -0
  75. data/lib/ace/coworker/version.rb +7 -0
  76. data/lib/ace/coworker.rb +86 -0
  77. metadata +319 -0
@@ -0,0 +1,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