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