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,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Coworker
|
|
7
|
+
module Molecules
|
|
8
|
+
# Atomic writer of the durable per-assignment record inside the task's
|
|
9
|
+
# own folder (<task_path>/coworker/<assignment_id>.md). Never touches
|
|
10
|
+
# task spec files, frontmatter, or status — the record is the
|
|
11
|
+
# contract-sanctioned per-task progress location.
|
|
12
|
+
class TaskRecordWriter
|
|
13
|
+
def initialize(task_path:)
|
|
14
|
+
@task_path = task_path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def record_path(assignment_id)
|
|
18
|
+
id = validated(assignment_id)
|
|
19
|
+
File.join(@task_path, "coworker", "#{id}.md")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write(assignment_id, content)
|
|
23
|
+
path = record_path(assignment_id)
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
25
|
+
tmp_path = "#{path}.tmp.#{Process.pid}"
|
|
26
|
+
File.write(tmp_path, content)
|
|
27
|
+
File.rename(tmp_path, path)
|
|
28
|
+
path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def validated(assignment_id)
|
|
34
|
+
id = assignment_id.to_s
|
|
35
|
+
unless id.match?(/\A[\w.-]+\z/) && !id.match?(/\A\.+\z/)
|
|
36
|
+
raise Error, "invalid assignment id '#{id}'"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
id
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Coworker
|
|
9
|
+
module Molecules
|
|
10
|
+
# Reads the executor truth of a worktree: its coworker config
|
|
11
|
+
# (resolved through the coworker config cascade anchored at the
|
|
12
|
+
# probed worktree — the same resolution an `ace-coworker` process
|
|
13
|
+
# running inside that worktree performs, so a commissioned
|
|
14
|
+
# `config_path` such as `.ace-local/coworker/worker.yml` is
|
|
15
|
+
# honored; read leniently) and the assignment inventory via
|
|
16
|
+
# `ace-assign list --all --format json` (subprocess argv contract
|
|
17
|
+
# with explicit cwd, ADR-031).
|
|
18
|
+
class WorktreeExecutorProbe
|
|
19
|
+
DEFAULT_RUNNER = ->(argv, cwd) { Open3.capture3(*argv, chdir: cwd) }
|
|
20
|
+
# Coworker config cascade files that may carry a `config_path`
|
|
21
|
+
# override for the probed worktree (project `.ace` and user tiers;
|
|
22
|
+
# the gem-defaults tier equals the loader's built-in default).
|
|
23
|
+
CASCADE_PATTERNS = ["coworker/config.yml", "coworker/config.yaml"].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :project_root
|
|
26
|
+
|
|
27
|
+
def initialize(project_root:, runner: DEFAULT_RUNNER)
|
|
28
|
+
@project_root = project_root
|
|
29
|
+
@runner = runner
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Probes one worktree. Never returns errors: an unreadable config is
|
|
33
|
+
# reported as Atoms::ActiveExecutorRule::UNPARSEABLE; operational
|
|
34
|
+
# failures (missing ace-assign CLI, failed listing, unparseable
|
|
35
|
+
# output) raise Error instead.
|
|
36
|
+
#
|
|
37
|
+
# @return [Hash] {config: Hash | nil | Atoms::ActiveExecutorRule::UNPARSEABLE,
|
|
38
|
+
# assignments: Array<Hash{"id","name","state","progress"}>}
|
|
39
|
+
# @raise [Error] when the assignment inventory cannot be read
|
|
40
|
+
def probe(worktree_path)
|
|
41
|
+
{
|
|
42
|
+
config: read_config(worktree_path),
|
|
43
|
+
assignments: list_assignments(worktree_path)
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def read_config(worktree_path)
|
|
50
|
+
# Deliberate read-only asymmetry (8xi16mz4): unlike
|
|
51
|
+
# CommissionOrchestrator#resolve_target, which refuses
|
|
52
|
+
# paths escaping the project root, the probe follows
|
|
53
|
+
# whatever config_path the cascade resolves — including an
|
|
54
|
+
# absolute or ../ path — because it only READS what an
|
|
55
|
+
# ace-coworker loader process inside that worktree would
|
|
56
|
+
# read anyway; there is no mutation to contain.
|
|
57
|
+
path = Molecules::ConfigLoader.new(
|
|
58
|
+
project_root: worktree_path,
|
|
59
|
+
defaults: cascade_defaults(worktree_path)
|
|
60
|
+
).config_path
|
|
61
|
+
return nil unless File.file?(path)
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
raw = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
|
|
65
|
+
rescue Psych::SyntaxError, StandardError
|
|
66
|
+
return Atoms::ActiveExecutorRule::UNPARSEABLE
|
|
67
|
+
end
|
|
68
|
+
raw.is_a?(Hash) ? raw : Atoms::ActiveExecutorRule::UNPARSEABLE
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Merge the coworker config cascade anchored at the probed
|
|
72
|
+
# worktree (project `.ace` tiers from the worktree up to its root,
|
|
73
|
+
# then the user tier) — the same file set and order
|
|
74
|
+
# ConfigResolver#resolve_file uses, restricted to the tiers that
|
|
75
|
+
# can override `config_path`. A layer that cannot contribute —
|
|
76
|
+
# YAML that is not a mapping (e.g. a list) or that fails to
|
|
77
|
+
# parse (e.g. unresolved merge-conflict markers in the
|
|
78
|
+
# worktree's own `.ace/coworker/config.yml`) — is skipped
|
|
79
|
+
# per-layer, mirroring the non-mapping case: wiping the
|
|
80
|
+
# already-merged lower tiers would silently drop a valid
|
|
81
|
+
# `config_path` override and hide a commissioned executor.
|
|
82
|
+
# A cascade that cannot even be enumerated falls back to the
|
|
83
|
+
# loader's default config location.
|
|
84
|
+
def cascade_defaults(worktree_path)
|
|
85
|
+
finder = Ace::Support::Config::Molecules::ConfigFinder.new(
|
|
86
|
+
file_patterns: CASCADE_PATTERNS, start_path: worktree_path)
|
|
87
|
+
layers = finder.find_all.select(&:exists).map(&:path)
|
|
88
|
+
merged = {}
|
|
89
|
+
layers.reverse_each do |path|
|
|
90
|
+
data = begin
|
|
91
|
+
Ace::Support::Config::Molecules::YamlLoader.load_file(path).data
|
|
92
|
+
rescue StandardError
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
merged = Ace::Support::Config::Atoms::DeepMerger.merge(merged, data) if data.is_a?(Hash)
|
|
96
|
+
end
|
|
97
|
+
merged
|
|
98
|
+
rescue StandardError
|
|
99
|
+
{}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def list_assignments(worktree_path)
|
|
103
|
+
argv = [assign_binary, "list", "--all", "--format", "json"]
|
|
104
|
+
begin
|
|
105
|
+
out, err, status = @runner.call(argv, worktree_path)
|
|
106
|
+
rescue Errno::ENOENT
|
|
107
|
+
raise Error, "required CLI '#{assign_binary}' not found; install the ace-assign gem"
|
|
108
|
+
end
|
|
109
|
+
unless status.success?
|
|
110
|
+
message = err.to_s.strip.empty? ? out.to_s.strip : err.to_s.strip
|
|
111
|
+
raise Error, "cannot list assignments in '#{worktree_path}': #{message}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
begin
|
|
115
|
+
payload = JSON.parse(out)
|
|
116
|
+
rescue JSON::ParserError => e
|
|
117
|
+
raise Error, "cannot parse ace-assign list for '#{worktree_path}': #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
return [] unless payload.is_a?(Array)
|
|
120
|
+
|
|
121
|
+
payload.filter_map do |entry|
|
|
122
|
+
next unless entry.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
{"id" => entry["id"].to_s, "name" => entry["name"].to_s,
|
|
125
|
+
"state" => entry["state"].to_s, "progress" => entry["progress"].to_s}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Prefer the repo binstub when present (agent shells do not activate
|
|
130
|
+
# mise, so bare ace-* may resolve to stale installed gems); the
|
|
131
|
+
# subprocess still runs with cwd = the probed worktree.
|
|
132
|
+
def assign_binary
|
|
133
|
+
binstub = File.join(@project_root, "bin", "ace-assign")
|
|
134
|
+
File.executable?(binstub) ? binstub : "ace-assign"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/git/worktree"
|
|
4
|
+
require_relative "../atoms/prepare_decision"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Coworker
|
|
8
|
+
module Molecules
|
|
9
|
+
# Wraps the worktree owner (ace-git-worktree) for the preparation
|
|
10
|
+
# decision procedure: association-aware find (switch), idempotent
|
|
11
|
+
# create, and stale-metadata recovery (prune + recreate, mirroring
|
|
12
|
+
# the proven ace-overseer provisioner pattern). Returns structured
|
|
13
|
+
# disposition hashes; never raises for expected outcomes.
|
|
14
|
+
class WorktreeProvisionProbe
|
|
15
|
+
def initialize(project_root:, manager: nil)
|
|
16
|
+
@project_root = project_root
|
|
17
|
+
@manager = manager || Ace::Git::Worktree::Organisms::WorktreeManager.new(project_root: project_root)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Runs the find/prepare/recover flow for one task.
|
|
21
|
+
#
|
|
22
|
+
# @return [Hash] {disposition:, reason:, worktree_path:, branch:}
|
|
23
|
+
# disposition: :created | :recovered | :reuse | :conflict_mismatch |
|
|
24
|
+
# :error | :would_create | :would_recover
|
|
25
|
+
def ensure_prepared(task_ref:, expected_task_id:, dry_run: false)
|
|
26
|
+
decision = locate(task_ref: task_ref, expected_task_id: expected_task_id)
|
|
27
|
+
|
|
28
|
+
case decision[:disposition]
|
|
29
|
+
when :reuse, :conflict_mismatch
|
|
30
|
+
decision
|
|
31
|
+
when :stale_recovery
|
|
32
|
+
recover_stale(task_ref: task_ref, decision: decision, dry_run: dry_run)
|
|
33
|
+
when :miss
|
|
34
|
+
prepare(task_ref: task_ref, dry_run: dry_run)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Read-only find: association-aware switch plus stale detection.
|
|
39
|
+
# The owner's switch never resolves a worktree whose directory is
|
|
40
|
+
# missing (it fails instead), so a failed switch is cross-checked
|
|
41
|
+
# against the association inventory: a registration for this task
|
|
42
|
+
# pointing at a missing directory is stale metadata, not a miss.
|
|
43
|
+
def locate(task_ref:, expected_task_id:)
|
|
44
|
+
switch = @manager.switch(task_ref.to_s)
|
|
45
|
+
stale = stale_registration(expected_task_id) unless switch[:success]
|
|
46
|
+
Atoms::PrepareDecision.from_switch(switch,
|
|
47
|
+
expected_task_id: expected_task_id, stale_registration: stale)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def stale_registration(expected_task_id)
|
|
53
|
+
listing = @manager.list_all
|
|
54
|
+
return nil unless listing[:success]
|
|
55
|
+
|
|
56
|
+
expected = Atoms::PrepareDecision.expected_ids(expected_task_id)
|
|
57
|
+
stale = listing[:worktrees].find do |info|
|
|
58
|
+
info.respond_to?(:task_id) && expected.include?(info.task_id.to_s) &&
|
|
59
|
+
!path_exists?(info.path)
|
|
60
|
+
end
|
|
61
|
+
return nil unless stale
|
|
62
|
+
|
|
63
|
+
{task_id: stale.task_id.to_s, worktree_path: stale.path, branch: stale.branch}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def prepare(task_ref:, dry_run:)
|
|
67
|
+
return {disposition: :would_create, reason: "no worktree for this task yet",
|
|
68
|
+
worktree_path: nil, branch: nil} if dry_run
|
|
69
|
+
|
|
70
|
+
create = @manager.create_task(task_ref.to_s)
|
|
71
|
+
return create_with_verification(task_ref: task_ref, create: create) if create[:success]
|
|
72
|
+
|
|
73
|
+
occupied = expected_directory_exists?(task_ref)
|
|
74
|
+
Atoms::PrepareDecision.from_create(create, occupied_directory: occupied)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def recover_stale(task_ref:, decision:, dry_run:)
|
|
78
|
+
return decision.merge(disposition: :would_recover) if dry_run
|
|
79
|
+
|
|
80
|
+
prune = @manager.prune
|
|
81
|
+
unless prune[:success]
|
|
82
|
+
return {disposition: :error,
|
|
83
|
+
reason: "#{prune[:error]} (could not prune stale worktree metadata at " \
|
|
84
|
+
"#{decision[:worktree_path]} before recreation)",
|
|
85
|
+
worktree_path: decision[:worktree_path], branch: nil}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
retry_create = @manager.create_task(task_ref.to_s)
|
|
89
|
+
if retry_create[:success]
|
|
90
|
+
return Atoms::PrepareDecision.from_create(retry_create.merge(recovered: true))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Same occupied-location rule as the miss path: a foreign
|
|
94
|
+
# occupant of the expected directory is a conflict, never an
|
|
95
|
+
# operational error.
|
|
96
|
+
occupied = path_exists?(decision[:worktree_path])
|
|
97
|
+
mismatch = Atoms::PrepareDecision.from_create(retry_create, occupied_directory: occupied)
|
|
98
|
+
return mismatch if mismatch[:disposition] == :conflict_mismatch
|
|
99
|
+
|
|
100
|
+
# The stale worktree's branch survives its directory (committed
|
|
101
|
+
# work lives on it): when recreation trips over the surviving
|
|
102
|
+
# branch, reattach a worktree to it instead of minting a fresh
|
|
103
|
+
# branch that would orphan that work.
|
|
104
|
+
reattached = @manager.create(decision[:branch].to_s, path: decision[:worktree_path])
|
|
105
|
+
return Atoms::PrepareDecision.from_create(reattached.merge(recovered: true)) if reattached[:success]
|
|
106
|
+
|
|
107
|
+
{disposition: :error,
|
|
108
|
+
reason: "#{retry_create[:error]} (stale worktree metadata at " \
|
|
109
|
+
"#{decision[:worktree_path]} was already pruned and branch reattach " \
|
|
110
|
+
"failed too; retry the prepare command)",
|
|
111
|
+
worktree_path: decision[:worktree_path], branch: nil}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def create_with_verification(task_ref:, create:)
|
|
115
|
+
decision = Atoms::PrepareDecision.from_create(create)
|
|
116
|
+
return decision if %i[conflict_mismatch error].include?(decision[:disposition])
|
|
117
|
+
return decision if path_exists?(create[:worktree_path])
|
|
118
|
+
|
|
119
|
+
recover_stale(task_ref: task_ref,
|
|
120
|
+
decision: {worktree_path: create[:worktree_path], branch: create[:branch]},
|
|
121
|
+
dry_run: false)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def expected_directory_exists?(task_ref)
|
|
125
|
+
preview = @manager.create_task(task_ref.to_s, {dry_run: true})
|
|
126
|
+
path = preview.dig(:would_create, :worktree_path) if preview[:success]
|
|
127
|
+
path && path_exists?(path)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def path_exists?(worktree_path)
|
|
131
|
+
path = worktree_path.to_s
|
|
132
|
+
!path.empty? && File.directory?(path)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Coworker
|
|
8
|
+
module Organisms
|
|
9
|
+
# Orchestrates `ace-coworker commission`: expand a role preset plus the
|
|
10
|
+
# commission identity (task, assignment, overseer) into the documented
|
|
11
|
+
# .ace-local/coworker/config.yml. A single-role writer primitive for
|
|
12
|
+
# the location ace-coworker already owns: no sequencing, no
|
|
13
|
+
# active-driver detection, no role policies (those belong to the
|
|
14
|
+
# planner, L4.2/L4.3).
|
|
15
|
+
class CommissionOrchestrator
|
|
16
|
+
Commission = Struct.new(
|
|
17
|
+
:role, :task, :assignment, :overseer, :config_path, :config,
|
|
18
|
+
:preset_source, :dry_run, :written,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
) do
|
|
21
|
+
def to_h
|
|
22
|
+
{
|
|
23
|
+
"role" => role,
|
|
24
|
+
"task" => task,
|
|
25
|
+
"assignment" => assignment,
|
|
26
|
+
"overseer" => overseer,
|
|
27
|
+
"config_path" => config_path,
|
|
28
|
+
"config" => config,
|
|
29
|
+
"preset_source" => preset_source,
|
|
30
|
+
"dry_run" => dry_run,
|
|
31
|
+
"written" => written
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param project_root [String] absolute project root
|
|
37
|
+
# @param preset_loader [Molecules::RolePresetLoader]
|
|
38
|
+
# @param task_probe [Molecules::TaskProbe]
|
|
39
|
+
# @param assignment_metadata [Molecules::AssignmentMetadata]
|
|
40
|
+
# @param config_path [String, nil] explicit target (absolute or
|
|
41
|
+
# root-relative); default from the config cascade
|
|
42
|
+
def initialize(project_root:, preset_loader:, task_probe:, assignment_metadata:,
|
|
43
|
+
config_path: nil, config_loader: nil)
|
|
44
|
+
@project_root = project_root
|
|
45
|
+
@preset_loader = preset_loader
|
|
46
|
+
@task_probe = task_probe
|
|
47
|
+
@assignment_metadata = assignment_metadata
|
|
48
|
+
@config_loader = config_loader ||
|
|
49
|
+
Molecules::ConfigLoader.new(project_root: project_root, defaults: Ace::Coworker.config)
|
|
50
|
+
@config_path = config_path
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Commission]
|
|
54
|
+
# @raise [Error] on unknown role, identity mismatch, invalid
|
|
55
|
+
# assembly, a refused overwrite, a config path escaping the
|
|
56
|
+
# project root, or a directory config target
|
|
57
|
+
def call(role:, task:, assignment:, overseer:, start_prompt: nil, force: false, dry_run: false)
|
|
58
|
+
preset = @preset_loader.fetch(role)
|
|
59
|
+
|
|
60
|
+
# Canonicalize the task identity first: the assignment match and
|
|
61
|
+
# the written config use the resolved full id, so a short ref
|
|
62
|
+
# (e.g. "ccc") commissioning the recorded task (8xh.t.ccc) is
|
|
63
|
+
# accepted while a genuinely foreign task stays rejected.
|
|
64
|
+
resolved = @task_probe.resolve(task)
|
|
65
|
+
raise Error, "task '#{task}' not found in this worktree" if resolved.nil?
|
|
66
|
+
|
|
67
|
+
canonical_task = resolved.id.to_s
|
|
68
|
+
@assignment_metadata.verify_task_match!(assignment, canonical_task)
|
|
69
|
+
|
|
70
|
+
prompt = start_prompt.to_s.strip.empty? ? render_prompt(preset, role, canonical_task, assignment, overseer) : start_prompt
|
|
71
|
+
|
|
72
|
+
config = build_config(preset, role: role, task: canonical_task, assignment: assignment,
|
|
73
|
+
overseer: overseer, prompt: prompt)
|
|
74
|
+
errors = Atoms::ConfigSchema.errors(config)
|
|
75
|
+
unless errors.empty?
|
|
76
|
+
raise Error, "commissioned config is invalid: #{errors.join('; ')}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
target = resolve_target
|
|
80
|
+
write_config(target, config, force: force) unless dry_run
|
|
81
|
+
|
|
82
|
+
Commission.new(
|
|
83
|
+
role: role, task: canonical_task, assignment: assignment, overseer: overseer,
|
|
84
|
+
config_path: relativize(target), config: config,
|
|
85
|
+
preset_source: preset["source"], dry_run: dry_run,
|
|
86
|
+
written: dry_run ? false : true
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def render_prompt(preset, role, task, assignment, overseer)
|
|
93
|
+
Atoms::PromptTemplate.render(preset["start_prompt"],
|
|
94
|
+
{"task" => task, "assignment" => assignment, "role" => role, "overseer" => overseer})
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_config(preset, role:, task:, assignment:, overseer:, prompt:)
|
|
98
|
+
config = {
|
|
99
|
+
"role" => role,
|
|
100
|
+
"instructions" => preset["instructions"],
|
|
101
|
+
"start_prompt" => prompt,
|
|
102
|
+
"task" => task,
|
|
103
|
+
"assignment" => assignment,
|
|
104
|
+
"overseer" => overseer
|
|
105
|
+
}
|
|
106
|
+
config["reporting"] = {"channel" => preset["reporting"]} unless preset["reporting"].to_s.empty?
|
|
107
|
+
config["errors"] = {"on_failure" => preset["errors"]} unless preset["errors"].to_s.empty?
|
|
108
|
+
config
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_target
|
|
112
|
+
target = @config_path ? File.expand_path(@config_path, @project_root) : @config_loader.config_path
|
|
113
|
+
unless target.start_with?("#{@project_root}/")
|
|
114
|
+
raise Error, "config path '#{relativize(target)}' escapes the project root"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
target
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def write_config(target, config, force:)
|
|
121
|
+
if File.directory?(target)
|
|
122
|
+
raise Error, "config path '#{relativize(target)}' is a directory; --config must name a file"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The exist? -> mv sequence below has a TOCTOU window on the
|
|
126
|
+
# overwrite refusal: commission is a single-planner local write
|
|
127
|
+
# primitive, so concurrent writers on the same config are out of
|
|
128
|
+
# scope by design; this check is a guard, not a hard guarantee.
|
|
129
|
+
if File.exist?(target) && !force
|
|
130
|
+
raise Error, "refusing to overwrite '#{relativize(target)}'; use --force"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
134
|
+
content = YAML.safe_dump(config)
|
|
135
|
+
Dir.mktmpdir(".commission", File.dirname(target)) do |tmp|
|
|
136
|
+
temp = File.join(tmp, "config.yml")
|
|
137
|
+
File.write(temp, content)
|
|
138
|
+
FileUtils.mv(temp, target)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def relativize(path)
|
|
143
|
+
path.start_with?(@project_root) ? path.delete_prefix("#{@project_root}/") : path
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|