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,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
|