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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Coworker
8
+ module Molecules
9
+ # Helper state for reporting runs (.ace-local/coworker/reports/
10
+ # <assignment_id>.json): the derived result and the created HITL event
11
+ # id, driving create-vs-resend on reruns. Coworker owns this location.
12
+ class ReportStateStore
13
+ def initialize(project_root:)
14
+ @project_root = project_root
15
+ end
16
+
17
+ def state_path(assignment_id)
18
+ id = validated(assignment_id)
19
+ File.join(@project_root, ".ace-local", "coworker", "reports", "#{id}.json")
20
+ end
21
+
22
+ def read(assignment_id)
23
+ path = state_path(assignment_id)
24
+ return nil unless File.file?(path)
25
+
26
+ JSON.parse(File.read(path))
27
+ rescue JSON::ParserError
28
+ nil
29
+ end
30
+
31
+ def write(assignment_id, state)
32
+ path = state_path(assignment_id)
33
+ FileUtils.mkdir_p(File.dirname(path))
34
+ tmp_path = "#{path}.tmp.#{Process.pid}"
35
+ File.write(tmp_path, JSON.pretty_generate(state))
36
+ File.rename(tmp_path, path)
37
+ path
38
+ end
39
+
40
+ private
41
+
42
+ def validated(assignment_id)
43
+ id = assignment_id.to_s
44
+ unless id.match?(/\A[\w.-]+\z/) && !id.match?(/\A\.+\z/)
45
+ raise Error, "invalid assignment id '#{id}'"
46
+ end
47
+
48
+ id
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require_relative "../atoms/round_rule"
6
+ require_relative "../atoms/round_record_schema"
7
+
8
+ module Ace
9
+ module Coworker
10
+ module Molecules
11
+ # File-based review-round history for one reviewer assignment
12
+ # (.ace-local/coworker/review-rounds/<assignment_id>.json), following
13
+ # ReportStateStore. Coworker owns this location; a restart reads the
14
+ # same file back, so the round counter and findings history survive
15
+ # (contract: "wznowienie zachowuje dotychczasowe ustalenia,
16
+ # liczniki i wyniki"). There is no separate journal - this file is
17
+ # the deterministic counterpart of the reviewer's step-report round
18
+ # record (reviewer.wf.md rule 6).
19
+ class ReviewRoundStore
20
+ def initialize(project_root:)
21
+ @project_root = project_root
22
+ end
23
+
24
+ def state_path(assignment_id)
25
+ File.join(@project_root, ".ace-local", "coworker", "review-rounds",
26
+ "#{validated(assignment_id)}.json")
27
+ end
28
+
29
+ # @return [Array<Hash>] recorded rounds, [] when absent or unparseable
30
+ # (lenient for pure readers; the destructive append in record!
31
+ # refuses corrupt state instead of resetting history).
32
+ def read(assignment_id)
33
+ path = state_path(assignment_id)
34
+ return [] unless File.file?(path)
35
+
36
+ parsed = JSON.parse(File.read(path))
37
+ parsed.is_a?(Array) ? parsed : []
38
+ rescue JSON::ParserError
39
+ []
40
+ end
41
+
42
+ # Persist the full round list atomically; the tmp file never
43
+ # survives a failed write.
44
+ def write(assignment_id, records)
45
+ path = state_path(assignment_id)
46
+ FileUtils.mkdir_p(File.dirname(path))
47
+ tmp_path = "#{path}.tmp.#{Process.pid}"
48
+ begin
49
+ File.write(tmp_path, JSON.pretty_generate(records))
50
+ File.rename(tmp_path, path)
51
+ ensure
52
+ File.delete(tmp_path) if File.exist?(tmp_path)
53
+ end
54
+ path
55
+ end
56
+
57
+ # Record one round and return the resulting rule verdict, so a
58
+ # resumed reviewer process reconstructs counters from the same
59
+ # file: `store.record!(id, round)` => RoundRule verdict hash.
60
+ #
61
+ # Single-writer assumption: record! is an unsynchronized
62
+ # read-modify-write, so concurrent record! calls on the same
63
+ # assignment are last-write-wins (an earlier interleaved round
64
+ # is lost). The lab flow runs one executor per worktree, so one
65
+ # reviewer process owns this file at a time; no locking is added.
66
+ def record!(assignment_id, record)
67
+ errors = Atoms::RoundRecordSchema.errors(record)
68
+ unless errors.empty?
69
+ raise Error, "invalid round record for '#{assignment_id}': #{errors.join('; ')}"
70
+ end
71
+
72
+ records = strict_read(assignment_id) + [record]
73
+ write(assignment_id, records)
74
+ Atoms::RoundRule.evaluate(records)
75
+ end
76
+
77
+ private
78
+
79
+ # Corrupt or non-array state must never be silently reset by an
80
+ # append: an unparseable or non-array file raises, because
81
+ # discarding it would erase the 10-round cap and the unresolved
82
+ # High/Critical history - the exact evidence this store exists
83
+ # to keep. An absent file is a legitimate empty history.
84
+ def strict_read(assignment_id)
85
+ path = state_path(assignment_id)
86
+ return [] unless File.file?(path)
87
+
88
+ parsed = begin
89
+ JSON.parse(File.read(path))
90
+ rescue JSON::ParserError
91
+ raise Error, "corrupt round history at #{path}"
92
+ end
93
+ raise Error, "corrupt round history at #{path}: expected an array, got #{parsed.class}" unless parsed.is_a?(Array)
94
+
95
+ parsed.each_with_index do |element, index|
96
+ element_errors = Atoms::RoundRecordSchema.errors(element)
97
+ unless element_errors.empty?
98
+ raise Error, "corrupt round history at #{path}: record #{index}: #{element_errors.join('; ')}"
99
+ end
100
+ end
101
+ parsed
102
+ end
103
+
104
+ def validated(assignment_id)
105
+ id = assignment_id.to_s
106
+ unless id.match?(/\A[\w.-]+\z/) && !id.match?(/\A\.+\z/)
107
+ raise Error, "invalid assignment id '#{id}'"
108
+ end
109
+
110
+ id
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module Molecules
8
+ # Discovers role presets from the shipped gem defaults
9
+ # (.ace-defaults/coworker/roles/*.yml) merged shallow-by-filename with
10
+ # project overrides (.ace/coworker/roles/*.yml, project wins). Every
11
+ # loaded preset is schema-validated; presets are convenience defaults,
12
+ # never a role allowlist (ADR-034: the overseer decides roles).
13
+ class RolePresetLoader
14
+ GEM_ROLES_DIR = File.join(".ace-defaults", "coworker", "roles")
15
+ PROJECT_ROLES_DIR = File.join(".ace", "coworker", "roles")
16
+
17
+ # @param project_root [String] absolute project root
18
+ # @param gem_roles_dir [String, nil] explicit gem roles dir (tests)
19
+ def initialize(project_root:, gem_roles_dir: nil, schema: Atoms::RolePresetSchema)
20
+ @project_root = project_root
21
+ @gem_roles_dir = gem_roles_dir || File.join(gem_root, GEM_ROLES_DIR)
22
+ @schema = schema
23
+ end
24
+
25
+ # @return [Array<Hash>] sorted presets, each with "name" and
26
+ # "source" ("gem"|"project") plus the normalized preset fields
27
+ def list
28
+ presets.values.sort_by { |preset| preset["name"] }
29
+ end
30
+
31
+ # @return [Hash] the preset for name (with "source")
32
+ # @raise [Error] when the name is unknown
33
+ def fetch(name)
34
+ key = name.to_s.strip
35
+ preset = presets[key]
36
+ raise Error, "unknown role '#{key}' (available: #{presets.keys.sort.join(', ')})" if preset.nil?
37
+
38
+ preset
39
+ end
40
+
41
+ private
42
+
43
+ def presets
44
+ @presets ||= begin
45
+ merged = {}
46
+ load_dir(@gem_roles_dir, "gem", merged)
47
+ load_dir(project_roles_dir, "project", merged)
48
+ merged
49
+ end
50
+ end
51
+
52
+ def project_roles_dir
53
+ File.join(@project_root, PROJECT_ROLES_DIR)
54
+ end
55
+
56
+ def load_dir(dir, source, merged)
57
+ Dir.glob(File.join(dir, "*.yml")).sort.each do |path|
58
+ preset = read_preset(path)
59
+ preset["source"] = source
60
+ merged[File.basename(path, ".yml")] = preset
61
+ end
62
+ end
63
+
64
+ def read_preset(path)
65
+ raw = begin
66
+ YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
67
+ rescue Psych::Exception => e
68
+ raise Error, "invalid role preset '#{relative(path)}': #{e.message}"
69
+ end
70
+ errors = @schema.errors(raw)
71
+ unless errors.empty?
72
+ raise Error, "invalid role preset '#{relative(path)}': #{errors.join('; ')}"
73
+ end
74
+
75
+ preset = @schema.normalize(raw)
76
+ basename = File.basename(path, ".yml")
77
+ unless preset["name"] == basename
78
+ raise Error,
79
+ "invalid role preset '#{relative(path)}': name field '#{preset['name']}' must match the filename '#{basename}.yml'"
80
+ end
81
+
82
+ preset
83
+ end
84
+
85
+ def relative(path)
86
+ path.start_with?(@project_root) ? path.delete_prefix("#{@project_root}/") : path
87
+ end
88
+
89
+ def gem_root
90
+ ::Gem.loaded_specs["ace-coworker"]&.gem_dir || File.expand_path("../../../..", __dir__)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/assign"
4
+ # Explicit (not transitive) requires for the ace-assign surface used below.
5
+ require "ace/assign/molecules/assignment_manager"
6
+ require "ace/assign/molecules/step_writer"
7
+ require "ace/assign/atoms/number_generator"
8
+
9
+ module Ace
10
+ module Coworker
11
+ module Molecules
12
+ # Composes the planner's role-sequence assignments (L4.2): ONE
13
+ # ace-assign assignment per role, in dependency order, stored by
14
+ # ace-assign in the prepared worktree's .ace-local/assign/ — the plan
15
+ # is the only place steps live (ADR-034; no second queue, no sequence
16
+ # copy in coworker config). Each role's process comes from its
17
+ # per-role sequence definition (shipped
18
+ # .ace-defaults/coworker/sequences/<role>.yml, project override
19
+ # .ace/coworker/sequences/<role>.yml), so worker and reviewer get
20
+ # different processes matched to the same task. A role without a
21
+ # preset or without its own process definition is reported and
22
+ # NOTHING is written: another role's process is never substituted.
23
+ # Composition is idempotent: an existing assignment named
24
+ # "<task>-<role>" with a matching task_id is reused without writes.
25
+ # dry_run resolves the same sequence with ZERO writes: existing
26
+ # assignments are read-only reused, missing legs are "planned".
27
+ class RoleSequenceComposer
28
+ GEM_SEQUENCES_DIR = File.join(".ace-defaults", "coworker", "sequences")
29
+ PROJECT_SEQUENCES_DIR = File.join(".ace", "coworker", "sequences")
30
+ ASSIGN_DIR = File.join(".ace-local", "assign")
31
+ # Step keys the composer itself owns: lifecycle-reserved frontmatter
32
+ # (status/added_by/parent) must never be overridable from definition
33
+ # data, and name/instructions are passed as explicit arguments.
34
+ RESERVED_STEP_KEYS = %w[name instructions status added_by parent].freeze
35
+
36
+ # @param project_root [String] absolute root the roles work in (the
37
+ # prepared worktree)
38
+ # @param preset_loader [RolePresetLoader, #fetch] role preset source
39
+ # @param gem_sequences_dir [String, nil] explicit gem sequences dir
40
+ def initialize(project_root:, preset_loader: nil, gem_sequences_dir: nil)
41
+ @project_root = project_root
42
+ @preset_loader = preset_loader || RolePresetLoader.new(project_root: project_root)
43
+ @gem_sequences_dir = gem_sequences_dir || File.join(gem_root, GEM_SEQUENCES_DIR)
44
+ end
45
+
46
+ # @param task [String] canonical task id
47
+ # @param roles [Array<String>] role sequence in dependency order
48
+ # @param dry_run [Boolean] resolve the whole sequence without any
49
+ # writes: existing assignments are reported as "existing" (a
50
+ # read-only lookup), missing legs as "planned" with a nil
51
+ # assignment id
52
+ # @return [Array<Hash>] ordered entries:
53
+ # {"role", "position", "of", "assignment", "disposition"
54
+ # ("created"|"existing"|"planned"), "steps", "outcome"}
55
+ # @raise [Error] when any role has no preset or no process
56
+ # definition; raised BEFORE any assignment is written
57
+ def call(task:, roles:, dry_run: false)
58
+ role_list = Array(roles).map(&:to_s).map(&:strip).reject(&:empty?)
59
+ raise Error, "role sequence requires at least one role" if role_list.empty?
60
+
61
+ # Resolve every role's process first: a missing process aborts
62
+ # the whole composition instead of leaving a partial sequence.
63
+ definitions = role_list.map { |role| load_definition(role) }
64
+
65
+ previous = nil
66
+ role_list.each_with_index.map do |role, index|
67
+ definition = definitions[index]
68
+ existing = find_existing(task, role)
69
+ entry = if existing
70
+ entry_for(task: task, role: role, index: index, total: role_list.length,
71
+ assignment_id: existing, definition: definition, disposition: "existing")
72
+ elsif dry_run
73
+ entry_for(task: task, role: role, index: index, total: role_list.length,
74
+ assignment_id: nil, definition: definition, disposition: "planned")
75
+ else
76
+ assignment_id = create_assignment(task: task, role: role,
77
+ definition: definition, index: index, total: role_list.length,
78
+ previous: previous)
79
+ entry_for(task: task, role: role, index: index, total: role_list.length,
80
+ assignment_id: assignment_id, definition: definition, disposition: "created")
81
+ end
82
+ previous = {"role" => role, "assignment" => entry["assignment"]}
83
+ entry
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def entry_for(task:, role:, index:, total:, assignment_id:, definition:, disposition:)
90
+ {
91
+ "role" => role,
92
+ "position" => index + 1,
93
+ "of" => total,
94
+ "assignment" => assignment_id,
95
+ "disposition" => disposition,
96
+ "steps" => definition["steps"].length,
97
+ "outcome" => render(definition["outcome"], {"task" => task, "role" => role})
98
+ }
99
+ end
100
+
101
+ def create_assignment(task:, role:, definition:, index:, total:, previous:)
102
+ context = {"task" => task, "role" => role}
103
+ description = render(definition["description"], context)
104
+ outcome = render(definition["outcome"], context)
105
+ description = "#{description} (sequence #{index + 1}/#{total}); expected outcome: #{outcome}"
106
+
107
+ manager = Ace::Assign::Molecules::AssignmentManager.new(
108
+ cache_base: File.join(@project_root, ASSIGN_DIR))
109
+ assignment = manager.create(
110
+ name: "#{task}-#{role}",
111
+ description: description,
112
+ source_config: definition["path"],
113
+ task_id: task)
114
+
115
+ writer = Ace::Assign::Molecules::StepWriter.new
116
+ entry_condition = previous ? entry_condition_line(previous) : nil
117
+ definition["steps"].each_with_index do |step, step_index|
118
+ instructions = step_instructions(step, context: context,
119
+ entry_condition: step_index.zero? ? entry_condition : nil)
120
+ extra = step.reject { |key, _| RESERVED_STEP_KEYS.include?(key) }
121
+ writer.create(
122
+ steps_dir: assignment.steps_dir,
123
+ number: Ace::Assign::Atoms::NumberGenerator.from_index(step_index),
124
+ name: step["name"],
125
+ instructions: instructions,
126
+ status: :pending,
127
+ extra: extra)
128
+ end
129
+
130
+ assignment.id
131
+ end
132
+
133
+ def entry_condition_line(previous)
134
+ "Entry condition: assignment #{previous['assignment']} (role #{previous['role']}) " \
135
+ "finished successfully before this role starts."
136
+ end
137
+
138
+ def step_instructions(step, context:, entry_condition: nil)
139
+ body = Array(step["instructions"])
140
+ .map { |line| render(line, context) }
141
+ .reject(&:empty?)
142
+ .join("\n")
143
+ lines = []
144
+ lines << entry_condition if entry_condition
145
+ lines << body
146
+ lines.reject(&:empty?).join("\n\n")
147
+ end
148
+
149
+ def render(template, context)
150
+ Atoms::PromptTemplate.render(template.to_s, context)
151
+ end
152
+
153
+ # Finds an existing sequence assignment for the task+role pair:
154
+ # exact name "<task>-<role>" with a matching recorded task_id.
155
+ # Read-only over ace-assign-owned files (like AssignmentMetadata).
156
+ def find_existing(task, role)
157
+ name = "#{task}-#{role}"
158
+ Dir.glob(File.join(@project_root, ASSIGN_DIR, "*", "assignment.yaml")).sort.each do |path|
159
+ data = YAML.safe_load_file(path, permitted_classes: [Time, Date], aliases: true) || {}
160
+ return File.basename(File.dirname(path)) if data["name"] == name && data["task_id"].to_s == task
161
+ end
162
+ nil
163
+ end
164
+
165
+ # Resolves the role's process definition: the preset must exist and
166
+ # the role must have its own sequence definition (project override
167
+ # first, then the shipped default). Never substitutes.
168
+ def load_definition(role)
169
+ @preset_loader.fetch(role) # raises Error for an unknown role
170
+ path = sequence_path(role)
171
+ if path.nil?
172
+ raise Error,
173
+ "role '#{role}' has no assignment process: no " \
174
+ "#{File.join(PROJECT_SEQUENCES_DIR, "#{role}.yml")} project override and no " \
175
+ "shipped default; refusing to substitute another role's process"
176
+ end
177
+
178
+ raw = begin
179
+ YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
180
+ rescue Psych::Exception => e
181
+ raise Error, "invalid assignment process '#{relative(path)}': #{e.message}"
182
+ end
183
+ errors = definition_errors(role, raw)
184
+ unless errors.empty?
185
+ raise Error, "invalid assignment process '#{relative(path)}': #{errors.join('; ')}"
186
+ end
187
+
188
+ raw.merge("path" => path)
189
+ end
190
+
191
+ def sequence_path(role)
192
+ project = File.join(@project_root, PROJECT_SEQUENCES_DIR, "#{role}.yml")
193
+ return project if File.file?(project)
194
+
195
+ shipped = File.join(@gem_sequences_dir, "#{role}.yml")
196
+ File.file?(shipped) ? shipped : nil
197
+ end
198
+
199
+ def definition_errors(role, raw)
200
+ # Mirror the RolePresetSchema raw-YAML guard: an empty file or a
201
+ # non-mapping document is an actionable process error, never a
202
+ # NoMethodError/TypeError on raw["role"].
203
+ return ["process is empty"] if raw.nil?
204
+ return ["process must be a mapping, got #{raw.class}"] unless raw.is_a?(Hash)
205
+
206
+ errors = []
207
+ errors << "required 'role' field is missing" if raw["role"].to_s.strip.empty?
208
+ if raw["role"].to_s != role
209
+ errors << "role field '#{raw["role"]}' must match the requested role '#{role}'"
210
+ end
211
+ errors << "required 'description' field is missing" if raw["description"].to_s.strip.empty?
212
+ errors << "required 'outcome' field is missing" if raw["outcome"].to_s.strip.empty?
213
+ steps = raw["steps"]
214
+ if !steps.is_a?(Array) || steps.empty?
215
+ errors << "at least one step is required"
216
+ else
217
+ steps.each_with_index do |step, index|
218
+ # A scalar/null step (steps: [42], steps: [null]) must be an
219
+ # actionable process error, never a TypeError from indexing a
220
+ # non-mapping — malformed user process data stays inside the
221
+ # role_sequence gap path.
222
+ unless step.is_a?(Hash)
223
+ errors << "step #{index + 1}: must be a mapping with 'name' and 'instructions', got #{step.class}"
224
+ next
225
+ end
226
+ errors << "step #{index + 1}: 'name' is required" if step["name"].to_s.strip.empty?
227
+ if Array(step["instructions"]).map(&:to_s).reject(&:empty?).empty?
228
+ errors << "step #{index + 1}: 'instructions' are required"
229
+ end
230
+ end
231
+ end
232
+ errors.uniq
233
+ end
234
+
235
+ def relative(path)
236
+ path.start_with?(@project_root) ? path.delete_prefix("#{@project_root}/") : path
237
+ end
238
+
239
+ def gem_root
240
+ ::Gem.loaded_specs["ace-coworker"]&.gem_dir || File.expand_path("../../../..", __dir__)
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module Molecules
8
+ # Reads failed-step evidence from the assignment's own step state
9
+ # (`.ace-local/assign/<id>/steps/<n>-*.st.md`, written by
10
+ # `ace-assign fail --message`). Read-only; ace-assign owns the files.
11
+ class StepEvidenceReader
12
+ def initialize(project_root:)
13
+ @project_root = project_root
14
+ end
15
+
16
+ # @param assignment_id [String]
17
+ # @param step_number [String, Integer, nil]
18
+ # @return [String, nil] the recorded error cause (nil when absent)
19
+ # @raise [Error] on an invalid assignment id (containment)
20
+ def read(assignment_id, step_number)
21
+ number = step_number.to_s.strip
22
+ return nil if number.empty?
23
+ unless number.match?(/\A[\w.-]+\z/)
24
+ raise Error, "invalid step number '#{number}'"
25
+ end
26
+
27
+ id = assignment_id.to_s
28
+ unless id.match?(/\A[\w.-]+\z/) && !id.match?(/\A\.+\z/)
29
+ raise Error, "invalid assignment id '#{id}'"
30
+ end
31
+
32
+ steps_dir = File.join(@project_root, ".ace-local", "assign", id, "steps")
33
+ return nil unless Dir.exist?(steps_dir)
34
+
35
+ path = Dir.glob(File.join(steps_dir, "#{step_number}-*.st.md")).sort.first
36
+ return nil unless path
37
+
38
+ error_from_step_file(path)
39
+ end
40
+
41
+ private
42
+
43
+ # The `error:` frontmatter line written by ace-assign fail carries
44
+ # the operator-visible cause. Parse the frontmatter as YAML so
45
+ # quoted/multiline values survive; fall back to a regex line scan.
46
+ def error_from_step_file(path)
47
+ content = File.read(path)
48
+ frontmatter = content.split(/^---\s*$/m)[1].to_s
49
+ parsed = YAML.safe_load(frontmatter, permitted_classes: [Date, Time], aliases: false)
50
+ cause = parsed.is_a?(Hash) ? parsed["error"].to_s.strip : ""
51
+ cause = content.match(/^error:\s*(.+)$/)[1].to_s.strip if cause.empty?
52
+ cause.empty? ? nil : cause
53
+ rescue StandardError
54
+ match = content.match(/^error:\s*(.+)$/)
55
+ match && !match[1].strip.empty? ? match[1].strip : nil
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Molecules
6
+ # Locates the in-flight step's report file
7
+ # (.ace-local/assign/<id>/reports/<n>-*.r.md). Read-only; ace-assign
8
+ # owns the files. Same id-containment rule as StepEvidenceReader.
9
+ class StepReportReader
10
+ def initialize(project_root:)
11
+ @project_root = project_root
12
+ end
13
+
14
+ # @return [Hash, nil] {path} for the step's report, nil absent
15
+ # @raise [Error] on an invalid assignment id (containment)
16
+ def read(assignment_id, step_number)
17
+ number = step_number.to_s.strip
18
+ return nil if number.empty?
19
+ unless number.match?(/\A[\w.-]+\z/)
20
+ raise Error, "invalid step number '#{number}'"
21
+ end
22
+
23
+ id = assignment_id.to_s
24
+ unless id.match?(/\A[\w.-]+\z/) && !id.match?(/\A\.+\z/)
25
+ raise Error, "invalid assignment id '#{id}'"
26
+ end
27
+
28
+ reports_dir = File.join(@project_root, ".ace-local", "assign", id, "reports")
29
+ return nil unless Dir.exist?(reports_dir)
30
+
31
+ path = Dir.glob(File.join(reports_dir, "#{step_number}-*.r.md")).sort.first
32
+ return nil unless path
33
+
34
+ {"path" => path}
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/task"
4
+ require "ace/task/organisms/task_manager"
5
+
6
+ module Ace
7
+ module Coworker
8
+ module Molecules
9
+ # Resolves the config task ref inside the project root using the
10
+ # ace-task library (same resolution ace-overseer relies on).
11
+ class TaskProbe
12
+ def initialize(project_root:, task_manager: nil)
13
+ @project_root = project_root
14
+ @task_manager = task_manager || Ace::Task::Organisms::TaskManager.new(root_dir: task_root)
15
+ end
16
+
17
+ # @return [Ace::Task::Models::Task, nil]
18
+ def resolve(ref)
19
+ @task_manager.show(ref.to_s)
20
+ end
21
+
22
+ # @return [String] absolute path of the task folder
23
+ # @raise [Error] when the ref does not resolve in this worktree
24
+ def resolve!(ref)
25
+ task = resolve(ref)
26
+ raise Error, "task '#{ref}' not found in this worktree" if task.nil?
27
+
28
+ dir = task.respond_to?(:path) ? task.path : nil
29
+ dir || File.join(task_root, task.id.to_s)
30
+ end
31
+
32
+ private
33
+
34
+ def task_root
35
+ File.join(@project_root, ".ace-tasks")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end