@guanlin4924/devflow-pi 0.1.12 → 0.1.14

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.
@@ -8,6 +8,7 @@ require "optparse"
8
8
  require "yaml"
9
9
  require_relative "control_io"
10
10
  require_relative "validate_state"
11
+ require_relative "phase_ownership"
11
12
 
12
13
  options = {
13
14
  state: nil,
@@ -35,7 +36,7 @@ OptionParser.new do |opts|
35
36
  end.parse!
36
37
 
37
38
  root = Dir.pwd
38
- state_path = options[:state] ? File.expand_path(options[:state], root) : File.join(root, ".planning", "STATE.md")
39
+ state_path, = DevflowControlIO.resolve_control_path(root, options[:state] || ".planning/STATE.md", :state)
39
40
 
40
41
  def front_matter(markdown)
41
42
  match = markdown.match(/\A---\n(.*?)\n---\n/m)
@@ -51,8 +52,9 @@ def workflow_path_for(root, state)
51
52
  end
52
53
 
53
54
  def load_workflow(root, state)
54
- path = workflow_path_for(root, state)
55
- [path, load_yaml_file(File.join(root, path))]
55
+ source = workflow_path_for(root, state)
56
+ path, relative = DevflowControlIO.resolve_control_path(root, source, :workflow)
57
+ [relative, load_yaml_file(path)]
56
58
  end
57
59
 
58
60
  def interpolate_path(path, state)
@@ -65,7 +67,7 @@ def load_artifact_registry(root, workflow)
65
67
  registry_path = workflow.dig("artifact_registry", "source")
66
68
  return {} unless registry_path
67
69
 
68
- abs = File.join(root, registry_path)
70
+ abs, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
69
71
  return {} unless File.exist?(abs)
70
72
 
71
73
  registry = load_yaml_file(abs)
@@ -79,7 +81,7 @@ def collect_outputs(workflow)
79
81
  end
80
82
 
81
83
  def content_validity(root, path, checks)
82
- abs = File.join(root, path)
84
+ abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
83
85
  return { "valid" => false, "issues" => ["file_missing"] } unless File.exist?(abs)
84
86
 
85
87
  issues = []
@@ -102,7 +104,7 @@ def build_artifact_registry(root, workflow, state)
102
104
  registry_path = workflow.dig("artifact_registry", "source")
103
105
  return [nil, nil] unless registry_path
104
106
 
105
- abs = File.join(root, registry_path)
107
+ abs, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
106
108
  existing_by_path = if File.exist?(abs)
107
109
  Array(load_yaml_file(abs)["artifacts"]).to_h { |artifact| [artifact.fetch("path"), artifact] }
108
110
  else
@@ -127,7 +129,8 @@ def build_artifact_registry(root, workflow, state)
127
129
  end
128
130
 
129
131
  validity = content_validity(root, path, content_checks)
130
- file_exists = File.exist?(File.join(root, path))
132
+ artifact_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
133
+ file_exists = File.exist?(artifact_path)
131
134
  status = if preservable_statuses.include?(existing["status"])
132
135
  existing["status"]
133
136
  else
@@ -169,7 +172,7 @@ def sync_artifact_registry(root, workflow, state, registry: nil)
169
172
  return nil unless registry_path
170
173
 
171
174
  registry ||= build_artifact_registry(root, workflow, state).last
172
- abs = File.join(root, registry_path)
175
+ abs, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
173
176
  DevflowControlIO.atomic_write(abs, registry.to_yaml)
174
177
  registry_path
175
178
  end
@@ -348,7 +351,7 @@ def apply_return_to!(root, state, branch_workflow)
348
351
  source_workflow_config = state["source_workflow_config"]
349
352
  return nil unless return_to && source_flow && source_workflow_config
350
353
 
351
- main_workflow_path = File.join(root, source_workflow_config)
354
+ main_workflow_path, = DevflowControlIO.resolve_control_path(root, source_workflow_config, :workflow)
352
355
  return nil unless File.exist?(main_workflow_path)
353
356
 
354
357
  main_workflow = load_yaml_file(main_workflow_path)
@@ -512,7 +515,8 @@ result =
512
515
  result = fail_result("missing_failure_branch", "current_step" => state["current_step"])
513
516
  else
514
517
  branch_path = branch.fetch("workflow_config")
515
- branch_workflow = load_yaml_file(File.join(root, branch_path))
518
+ branch_workflow_path, branch_path = DevflowControlIO.resolve_control_path(root, branch_path, :workflow)
519
+ branch_workflow = load_yaml_file(branch_workflow_path)
516
520
  transition = branch_workflow.dig("state_contract", "suggested_transition") || {}
517
521
  state["source_flow"] = state["current_flow"]
518
522
  state["source_workflow_config"] = workflow_path
@@ -553,6 +557,13 @@ result =
553
557
  state["revision"] = current_revision + 1
554
558
  workflow_path, workflow = load_workflow(root, state)
555
559
  end
560
+ prospective_owner = if changed
561
+ {
562
+ "phase" => state["current_phase"], "feature" => state["current_feature"],
563
+ "flow" => state["current_flow"], "state_path" => ".planning/STATE.md",
564
+ "scope" => "global", "status" => "active"
565
+ }
566
+ end
556
567
  # Build the registry from the candidate state before validating it. A
557
568
  # step's output is normally written before this transition, so the
558
569
  # persisted registry may still describe the previous state revision.
@@ -564,16 +575,37 @@ result =
564
575
  state_override: state,
565
576
  artifact_registry_override: candidate_registry,
566
577
  strict_artifact_status: false,
567
- strict_registry_revision: false
578
+ strict_registry_revision: false,
579
+ phase_owner_override: prospective_owner
568
580
  )
569
581
 
570
582
  if changed && !validation["ok"]
571
583
  result = fail_result("state_validation_failed", "validation" => validation)
572
584
  elsif changed && options[:write]
573
- sync_artifact_registry(root, workflow, state, registry: candidate_registry)
574
- artifact_by_path = load_artifact_registry(root, workflow)
575
- write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
576
- next_intent_written = write_next_intent(root, workflow) if next_intent_requested
585
+ ownership = PhaseOwnership.claim(
586
+ root: root, feature: state["current_feature"], phase: state["current_phase"],
587
+ flow: state["current_flow"], write: true
588
+ )
589
+ if !ownership["ok"]
590
+ result = fail_result("phase_ownership_claim_failed", "ownership" => ownership)
591
+ else
592
+ sync_artifact_registry(root, workflow, state, registry: candidate_registry)
593
+ artifact_by_path = load_artifact_registry(root, workflow)
594
+ write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
595
+ # STATE.md is project-global. Once its top-level workflow is
596
+ # terminal, retain the historical owner but release its active
597
+ # reservation so the next feature can claim a distinct phase.
598
+ if %w[completed failed cancelled].include?(state["status"]) && Array(state["pending_steps"]).empty? && state["source_flow"].nil?
599
+ completed_owner = PhaseOwnership.complete(
600
+ root: root, feature: state["current_feature"], phase: state["current_phase"],
601
+ flow: state["current_flow"], write: true
602
+ )
603
+ unless completed_owner["ok"]
604
+ result = fail_result("phase_ownership_completion_failed", "ownership" => completed_owner)
605
+ end
606
+ end
607
+ next_intent_written = write_next_intent(root, workflow) if next_intent_requested
608
+ end
577
609
  end
578
610
 
579
611
  result ||= {
@@ -2,14 +2,56 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
+ require "pathname"
5
6
  require "securerandom"
6
7
 
7
8
  # Shared durability primitives for every writer in the Devflow control plane.
8
9
  # The project lock serializes state, workflow, registry, and next-intent updates;
9
10
  # atomic rename prevents readers from observing partially written documents.
10
11
  module DevflowControlIO
12
+ class InvalidControlPath < StandardError; end
13
+
11
14
  module_function
12
15
 
16
+ # Control-plane files are not general-purpose CLI file arguments. Keeping
17
+ # their location fixed prevents a crafted STATE/workflow/registry reference
18
+ # from turning recovery or synchronization into an out-of-project writer.
19
+ def resolve_control_path(root, raw, kind)
20
+ root_path = File.realpath(root)
21
+ value = raw.to_s
22
+ raise InvalidControlPath, "#{kind} path is required" if value.empty?
23
+ raise InvalidControlPath, "#{kind} path must be project-relative" if Pathname.new(value).absolute?
24
+ raise InvalidControlPath, "#{kind} path contains control characters" if value.match?(/[\x00-\x1F\x7F]/)
25
+
26
+ normalized = Pathname.new(value).cleanpath.to_s.tr("\\", "/")
27
+ raise InvalidControlPath, "#{kind} path cannot traverse parent directories" if normalized == ".." || normalized.start_with?("../")
28
+ allowed = case kind
29
+ when :state then normalized == ".planning/STATE.md"
30
+ when :workflow then normalized.match?(%r{\A\.planning/workflows/[A-Za-z0-9][A-Za-z0-9_-]*\.yaml\z})
31
+ when :registry then normalized == ".planning/artifacts/index.yaml"
32
+ when :artifact then normalized.start_with?(".planning/") || normalized.start_with?(".context/")
33
+ else false
34
+ end
35
+ raise InvalidControlPath, "invalid #{kind} control path" unless allowed
36
+
37
+ absolute = File.expand_path(normalized, root_path)
38
+ raise InvalidControlPath, "#{kind} path escapes project root" unless absolute.start_with?("#{root_path}#{File::SEPARATOR}")
39
+ # Existing symlinks in any parent component are equally unsafe: a write
40
+ # through one could escape after lexical normalization.
41
+ cursor = root_path
42
+ normalized.split("/").each do |segment|
43
+ cursor = File.join(cursor, segment)
44
+ next unless File.exist?(cursor) || File.symlink?(cursor)
45
+ next unless File.symlink?(cursor)
46
+
47
+ resolved = File.realpath(cursor)
48
+ unless resolved == root_path || resolved.start_with?("#{root_path}#{File::SEPARATOR}")
49
+ raise InvalidControlPath, "#{kind} path escapes project through a symlink"
50
+ end
51
+ end
52
+ [absolute, normalized]
53
+ end
54
+
13
55
  def acquire_project_lock(root)
14
56
  planning_dir = File.join(root, ".planning")
15
57
  FileUtils.mkdir_p(planning_dir)
@@ -2,11 +2,13 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "date"
5
+ require "digest"
5
6
  require "fileutils"
6
7
  require "json"
7
8
  require "optparse"
8
9
  require "yaml"
9
10
  require_relative "control_io"
11
+ require_relative "phase_ownership"
10
12
 
11
13
  options = {
12
14
  feature: nil,
@@ -36,7 +38,12 @@ OptionParser.new do |opts|
36
38
  end.parse!
37
39
 
38
40
  root = Dir.pwd
39
- state_path = File.expand_path(options[:state], root)
41
+ begin
42
+ state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
43
+ rescue DevflowControlIO::InvalidControlPath => error
44
+ puts JSON.pretty_generate({ "ok" => false, "reason" => "invalid_control_path", "message" => error.message })
45
+ exit 0
46
+ end
40
47
 
41
48
  def front_matter(markdown)
42
49
  match = markdown.match(/\A---\n(.*?)\n---\n/m)
@@ -44,7 +51,13 @@ def front_matter(markdown)
44
51
  end
45
52
 
46
53
  feature = options[:feature] || File.basename(root)
47
- phase = options[:phase] || "01-#{feature}"
54
+ # CLI callers get the same safe invariant as the extension entrypoint: phase
55
+ # identifiers never derive from a user-facing feature label or a shared name.
56
+ phase = options[:phase] || "feature-#{Digest::SHA256.hexdigest(feature)[0, 12]}"
57
+ unless PhaseOwnership.valid_slug?(phase)
58
+ puts JSON.pretty_generate({ "ok" => false, "reason" => "invalid_phase", "message" => "phase must be a safe non-empty slug" })
59
+ exit 0
60
+ end
48
61
  workflow_config = ".planning/workflows/#{options[:flow]}.yaml"
49
62
  workflow_path = File.expand_path(workflow_config, root)
50
63
  fallback_pending_steps = [
@@ -104,6 +117,11 @@ state["active_track"] = options[:track] if %w[A B].include?(options[:track].to_s
104
117
  project_lock = DevflowControlIO.acquire_project_lock(root) if options[:write]
105
118
 
106
119
  begin
120
+ ownership = PhaseOwnership.claim(root: root, feature: feature, phase: phase, flow: options[:flow], write: options[:write])
121
+ unless ownership["ok"]
122
+ puts JSON.pretty_generate(ownership)
123
+ exit 0
124
+ end
107
125
  existing = File.exist?(state_path) ? File.read(state_path) : nil
108
126
  existing_front = existing && front_matter(existing)
109
127
  action =
@@ -129,6 +147,7 @@ result = {
129
147
  "written" => options[:write] && action != "skip_existing_front_matter",
130
148
  "path" => options[:state],
131
149
  "action" => action,
150
+ "phase_ownership" => ownership,
132
151
  "state" => state
133
152
  }
134
153
  ensure
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The control plane has one STATE.md, so phases must be claimed serially. This
5
+ # registry deliberately lives beside STATE.md instead of inside a phase folder:
6
+ # it remains visible even when a caller has selected the wrong phase directory.
7
+ require "date"
8
+ require "json"
9
+ require "optparse"
10
+ require "yaml"
11
+ require_relative "control_io"
12
+
13
+ module PhaseOwnership
14
+ REGISTRY_PATH = ".planning/phase-ownership.yaml"
15
+ PHASE_SLUG = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
16
+ module_function
17
+
18
+ def valid_slug?(value)
19
+ value.is_a?(String) && value.match?(PHASE_SLUG)
20
+ end
21
+
22
+ # Feature labels are user-facing names and may be Unicode. They are never
23
+ # used as a registry filename here, but reject separators/control characters
24
+ # so a later caller cannot reinterpret them as a path.
25
+ def valid_feature?(value)
26
+ value.is_a?(String) && !value.strip.empty? && !value.match?(/[\\\/\x00-\x1F\x7F]/)
27
+ end
28
+
29
+ def registry_path(root)
30
+ File.join(root, REGISTRY_PATH)
31
+ end
32
+
33
+ def load_registry(root)
34
+ path = registry_path(root)
35
+ return [nil, nil] unless File.exist?(path)
36
+
37
+ registry = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
38
+ return [nil, "invalid_phase_ownership_registry"] unless registry.is_a?(Hash) && registry["schema_version"] == 1 && registry["owners"].is_a?(Hash)
39
+
40
+ [registry, nil]
41
+ rescue Psych::Exception
42
+ [nil, "invalid_phase_ownership_registry"]
43
+ end
44
+
45
+ def valid_owner?(owner, phase: nil)
46
+ owner.is_a?(Hash) && valid_slug?(owner["phase"]) && (phase.nil? || owner["phase"] == phase) &&
47
+ valid_feature?(owner["feature"]) && owner["flow"].is_a?(String) && !owner["flow"].empty? &&
48
+ owner["state_path"] == ".planning/STATE.md" && %w[active completed].include?(owner["status"])
49
+ end
50
+
51
+ def nonempty_phase_dirs(root)
52
+ Dir.glob(File.join(root, ".planning", "phases", "*")).select do |path|
53
+ File.directory?(path) && !Dir.children(path).empty?
54
+ end.map { |path| File.basename(path) }
55
+ end
56
+
57
+ def claim(root:, feature:, phase:, flow:, write:)
58
+ return failure("invalid_phase", "phase must be a safe non-empty slug") unless valid_slug?(phase)
59
+ return failure("invalid_feature", "feature must be non-empty and cannot contain path separators or control characters") unless valid_feature?(feature)
60
+ return failure("invalid_flow", "flow is required") unless flow.is_a?(String) && !flow.empty?
61
+
62
+ registry, error = load_registry(root)
63
+ return failure(error, "phase ownership registry is malformed") if error
64
+ registry ||= { "schema_version" => 1, "owners" => {} }
65
+ owners = registry["owners"]
66
+
67
+ nonempty_phase_dirs(root).each do |phase_dir|
68
+ owner = owners[phase_dir]
69
+ next if valid_owner?(owner, phase: phase_dir)
70
+
71
+ return failure("legacy_phase_directory_without_owner", "non-empty phase directory has no valid ownership record", phase: phase_dir)
72
+ end
73
+
74
+ owners.each do |owner_phase, owner|
75
+ return failure("invalid_phase_owner", "phase ownership record is invalid", phase: owner_phase) unless valid_owner?(owner, phase: owner_phase)
76
+ next unless owner["status"] == "active"
77
+ next if owner["phase"] == phase && owner["feature"] == feature
78
+
79
+ return failure("active_phase_owned_by_another_feature", "an active phase already owns the global state", owner: owner)
80
+ end
81
+
82
+ current = owners[phase]
83
+ if current && current["feature"] != feature
84
+ return failure("phase_owned_by_another_feature", "requested phase is already owned by another feature", owner: current)
85
+ end
86
+
87
+ owner = current || { "phase" => phase, "feature" => feature, "flow" => flow, "state_path" => ".planning/STATE.md", "scope" => "global", "status" => "active" }
88
+ owner = owner.merge("phase" => phase, "feature" => feature, "flow" => flow, "state_path" => ".planning/STATE.md", "scope" => "global", "status" => "active")
89
+ registry["owners"][phase] = owner
90
+ registry["updated_at"] = Date.today.to_s
91
+ DevflowControlIO.atomic_write(registry_path(root), registry.to_yaml) if write
92
+ { "ok" => true, "written" => write, "action" => current ? "idempotent_claim" : "claim", "owner" => owner, "path" => REGISTRY_PATH }
93
+ end
94
+
95
+ def complete(root:, feature:, phase:, flow:, write:)
96
+ registry, error = load_registry(root)
97
+ return failure(error || "missing_phase_owner", "phase ownership record is required") unless registry
98
+ owner = registry["owners"][phase]
99
+ unless valid_owner?(owner, phase: phase) && owner["feature"] == feature && owner["flow"] == flow
100
+ return failure("phase_owner_mismatch", "cannot complete a phase owned by another feature")
101
+ end
102
+ owner["status"] = "completed"
103
+ registry["updated_at"] = Date.today.to_s
104
+ DevflowControlIO.atomic_write(registry_path(root), registry.to_yaml) if write
105
+ { "ok" => true, "written" => write, "action" => "complete", "owner" => owner, "path" => REGISTRY_PATH }
106
+ end
107
+
108
+ def validate_state(root:, state:, owner_override: nil)
109
+ registry, error = load_registry(root)
110
+ return failure(error || "missing_phase_owner", "STATE.md has no phase ownership record") unless registry
111
+ phase = state["current_phase"]
112
+ feature = state["current_feature"]
113
+ flow = state["current_flow"]
114
+ return failure("invalid_phase", "STATE.md current_phase must be a safe non-empty slug") unless valid_slug?(phase)
115
+ owner = owner_override || registry["owners"][phase]
116
+ return failure("missing_phase_owner", "STATE.md phase has no ownership record", phase: phase) unless valid_owner?(owner, phase: phase)
117
+ unless owner["feature"] == feature && owner["flow"] == flow && owner["state_path"] == ".planning/STATE.md"
118
+ return failure("phase_owner_mismatch", "STATE.md does not match phase ownership record", owner: owner)
119
+ end
120
+ conflicting = registry["owners"].values.find { |entry| valid_owner?(entry) && entry["status"] == "active" && entry["phase"] != phase }
121
+ return failure("active_phase_ownership_conflict", "another active phase owns the global state", owner: conflicting) if conflicting
122
+ { "ok" => true, "owner" => owner, "path" => REGISTRY_PATH }
123
+ end
124
+
125
+ def failure(reason, message, extra = {})
126
+ { "ok" => false, "reason" => reason, "message" => message }.merge(extra)
127
+ end
128
+ end
129
+
130
+ if __FILE__ == $PROGRAM_NAME
131
+ options = { feature: nil, phase: nil, flow: "feature-development", write: false, json: false }
132
+ command = ARGV.shift
133
+ OptionParser.new do |opts|
134
+ opts.banner = "Usage: phase_ownership.rb claim|inspect|complete --feature NAME --phase ID --flow ID [--write] [--json]"
135
+ opts.on("--feature NAME") { |value| options[:feature] = value }
136
+ opts.on("--phase ID") { |value| options[:phase] = value }
137
+ opts.on("--flow ID") { |value| options[:flow] = value }
138
+ opts.on("--write") { options[:write] = true }
139
+ opts.on("--json") { options[:json] = true }
140
+ end.parse!
141
+ root = Dir.pwd
142
+ lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
143
+ begin
144
+ result = case command
145
+ when "claim" then PhaseOwnership.claim(root: root, feature: options[:feature], phase: options[:phase], flow: options[:flow], write: options[:write])
146
+ when "complete" then PhaseOwnership.complete(root: root, feature: options[:feature], phase: options[:phase], flow: options[:flow], write: options[:write])
147
+ when "inspect" then PhaseOwnership.validate_state(root: root, state: { "current_feature" => options[:feature], "current_phase" => options[:phase], "current_flow" => options[:flow] })
148
+ else PhaseOwnership.failure("invalid_command", "command must be claim, inspect, or complete")
149
+ end
150
+ ensure
151
+ DevflowControlIO.release_lock(lock)
152
+ end
153
+ puts JSON.pretty_generate(result)
154
+ end
@@ -7,6 +7,7 @@ require "optparse"
7
7
  require "yaml"
8
8
  require_relative "control_io"
9
9
  require_relative "validate_state"
10
+ require_relative "phase_ownership"
10
11
 
11
12
  options = { state: ".planning/STATE.md", expected_revision: nil, confirm: false, write: false, json: false }
12
13
  OptionParser.new do |opts|
@@ -31,7 +32,7 @@ def build_registry(root, workflow, state)
31
32
  artifacts = Array(workflow["steps"]).flat_map do |step|
32
33
  Array(step["outputs"]).map do |output|
33
34
  path = interpolate_path(output.fetch("path"), state)
34
- abs = File.join(root, path)
35
+ abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
35
36
  exists = File.exist?(abs)
36
37
  issues = []
37
38
  issues << "file_missing" unless exists
@@ -53,43 +54,51 @@ def build_registry(root, workflow, state)
53
54
  end
54
55
 
55
56
  root = Dir.pwd
56
- state_path = File.expand_path(options[:state], root)
57
57
  result = nil
58
58
  lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
59
59
  begin
60
+ state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
60
61
  unless File.exist?(state_path)
61
62
  result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_state_file" }
62
63
  else
63
64
  state = YAML.safe_load(front_matter(File.read(state_path)), permitted_classes: [], aliases: false)
64
- workflow_path = File.expand_path(state.fetch("workflow_config"), root)
65
- workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
66
- registry_source = workflow.dig("artifact_registry", "source")
67
- if !registry_source.is_a?(String) || registry_source.empty?
68
- result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_artifact_registry_config", "plan" => { "state_transition" => "none", "registry_write" => false } }
65
+ ownership = PhaseOwnership.validate_state(root: root, state: state)
66
+ unless ownership["ok"]
67
+ result = { "ok" => false, "classification" => "unsafe", "reason" => ownership["reason"], "issues" => [ownership], "plan" => { "state_transition" => "none", "registry_write" => false } }
69
68
  else
70
- candidate = build_registry(root, workflow, state)
71
- validation = WorkflowStateValidator.validate(root: root, state_path: state_path, state_override: state, workflow_override: workflow, artifact_registry_override: candidate, strict_artifact_status: true, strict_registry_revision: true)
72
- current = Array(workflow["steps"]).find { |step| step["id"] == state["current_step"] }
73
- missing_current_outputs = Array(current && current["outputs"]).map do |output|
74
- next unless output["required"]
75
- path = interpolate_path(output.fetch("path"), state)
76
- path unless File.exist?(File.join(root, path))
77
- end.compact
78
- if missing_current_outputs.any?
79
- result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_required_output", "missing_outputs" => missing_current_outputs, "plan" => { "state_transition" => "none", "registry_write" => false } }
80
- elsif !validation["ok"]
81
- result = { "ok" => false, "classification" => "unsafe", "reason" => "ambiguous_or_invalid_state", "issues" => validation["errors"], "plan" => { "state_transition" => "none", "registry_write" => false } }
82
- elsif !options[:confirm] || !options[:write]
83
- result = { "ok" => false, "classification" => "safe_repairable", "reason" => "confirmation_required", "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true, "registry" => candidate } }
84
- elsif options[:expected_revision] != state.fetch("revision", 0)
85
- result = { "ok" => false, "classification" => "unsafe", "reason" => "stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => state.fetch("revision", 0) }
69
+ workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
70
+ workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
71
+ registry_source = workflow.dig("artifact_registry", "source")
72
+ if !registry_source.is_a?(String) || registry_source.empty?
73
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_artifact_registry_config", "plan" => { "state_transition" => "none", "registry_write" => false } }
86
74
  else
87
- registry_path = File.join(root, registry_source)
88
- DevflowControlIO.atomic_write(registry_path, candidate.to_yaml)
89
- result = { "ok" => true, "classification" => "safe_repairable", "written" => true, "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true } }
75
+ candidate = build_registry(root, workflow, state)
76
+ validation = WorkflowStateValidator.validate(root: root, state_path: state_path, state_override: state, workflow_override: workflow, artifact_registry_override: candidate, strict_artifact_status: true, strict_registry_revision: true)
77
+ current = Array(workflow["steps"]).find { |step| step["id"] == state["current_step"] }
78
+ missing_current_outputs = Array(current && current["outputs"]).map do |output|
79
+ next unless output["required"]
80
+ path = interpolate_path(output.fetch("path"), state)
81
+ output_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
82
+ path unless File.exist?(output_path)
83
+ end.compact
84
+ if missing_current_outputs.any?
85
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_required_output", "missing_outputs" => missing_current_outputs, "plan" => { "state_transition" => "none", "registry_write" => false } }
86
+ elsif !validation["ok"]
87
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "ambiguous_or_invalid_state", "issues" => validation["errors"], "plan" => { "state_transition" => "none", "registry_write" => false } }
88
+ elsif !options[:confirm] || !options[:write]
89
+ result = { "ok" => false, "classification" => "safe_repairable", "reason" => "confirmation_required", "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true, "registry" => candidate } }
90
+ elsif options[:expected_revision] != state.fetch("revision", 0)
91
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => state.fetch("revision", 0) }
92
+ else
93
+ registry_path, = DevflowControlIO.resolve_control_path(root, registry_source, :registry)
94
+ DevflowControlIO.atomic_write(registry_path, candidate.to_yaml)
95
+ result = { "ok" => true, "classification" => "safe_repairable", "written" => true, "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true } }
96
+ end
90
97
  end
91
98
  end
92
99
  end
100
+ rescue DevflowControlIO::InvalidControlPath => error
101
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "invalid_control_path", "message" => error.message, "plan" => { "state_transition" => "none", "registry_write" => false } }
93
102
  ensure
94
103
  DevflowControlIO.release_lock(lock)
95
104
  end
@@ -7,6 +7,7 @@ require "json"
7
7
  require "optparse"
8
8
  require "yaml"
9
9
  require_relative "control_io"
10
+ require_relative "phase_ownership"
10
11
 
11
12
  options = {
12
13
  state: ".planning/STATE.md",
@@ -63,11 +64,18 @@ end
63
64
  project_lock = DevflowControlIO.acquire_project_lock(root) if options[:write]
64
65
 
65
66
  begin
66
- state = load_state(File.join(root, options[:state]))
67
- workflow_path = options[:workflow] || state["workflow_config"] || ".planning/workflows/#{state.fetch("current_flow")}.yaml"
68
- workflow = load_yaml_file(File.join(root, workflow_path))
69
- registry_path = options[:output] || workflow.dig("artifact_registry", "source") || ".planning/artifacts/index.yaml"
70
- existing_registry_path = File.join(root, registry_path)
67
+ state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
68
+ state = load_state(state_path)
69
+ ownership = PhaseOwnership.validate_state(root: root, state: state)
70
+ unless ownership["ok"]
71
+ puts JSON.pretty_generate(ownership.merge("written" => false))
72
+ exit 0
73
+ end
74
+ workflow_source = options[:workflow] || state["workflow_config"] || ".planning/workflows/#{state.fetch("current_flow")}.yaml"
75
+ workflow_path, = DevflowControlIO.resolve_control_path(root, workflow_source, :workflow)
76
+ workflow = load_yaml_file(workflow_path)
77
+ registry_source = options[:output] || workflow.dig("artifact_registry", "source") || ".planning/artifacts/index.yaml"
78
+ existing_registry_path, registry_path = DevflowControlIO.resolve_control_path(root, registry_source, :registry)
71
79
  existing_by_path = {}
72
80
  if options[:preserve] && File.exist?(existing_registry_path)
73
81
  existing = load_yaml_file(existing_registry_path)
@@ -80,7 +88,7 @@ content_checks = workflow.dig("artifact_registry", "content_checks") || {}
80
88
  preservable_statuses = %w[approved completed passed pending_rerun stale failed]
81
89
 
82
90
  def content_validity(root, path, checks)
83
- abs = File.join(root, path)
91
+ abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
84
92
  return { "valid" => false, "issues" => ["file_missing"] } unless File.exist?(abs)
85
93
 
86
94
  issues = []
@@ -114,7 +122,8 @@ artifacts = collect_outputs(workflow).map do |step, output|
114
122
  end
115
123
 
116
124
  validity = content_validity(root, path, content_checks)
117
- file_exists = File.exist?(File.join(root, path))
125
+ artifact_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
126
+ file_exists = File.exist?(artifact_path)
118
127
  status = if options[:preserve] && preservable_statuses.include?(existing["status"])
119
128
  existing["status"]
120
129
  else
@@ -161,6 +170,8 @@ result = {
161
170
  "missing_count" => artifacts.count { |artifact| !artifact["exists"] },
162
171
  "registry" => registry
163
172
  }
173
+ rescue DevflowControlIO::InvalidControlPath => error
174
+ result = { "ok" => false, "written" => false, "reason" => "invalid_control_path", "message" => error.message }
164
175
  ensure
165
176
  DevflowControlIO.release_lock(project_lock)
166
177
  end