@guanlin4924/devflow-pi 0.1.13 → 0.1.15

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 (19) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +119 -12
  3. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +4 -4
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +47 -15
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +42 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +21 -2
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/phase_ownership.rb +154 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/repair_control_plane.rb +35 -26
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +18 -7
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/traceability_check.py +160 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +60 -8
  12. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +25 -3
  13. package/plugins/devflow-core/skills/workflow-orchestrator/templates/traceability.json +23 -0
  14. package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +4 -1
  15. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +4 -0
  16. package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +4 -0
  17. package/plugins/devflow-design/skills/workflow-integration-verify/SKILL.md +10 -0
  18. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +8 -0
  19. package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +9 -0
@@ -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
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """Fail-closed requirement-to-delivery traceability validation."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ STAGES = ("baseline", "design", "change-plan", "verify")
11
+ DIMENSIONS = ("input", "output", "behavior", "orchestration", "failure", "verification")
12
+
13
+
14
+ def fail(errors: list[str], message: str) -> None:
15
+ errors.append(message)
16
+
17
+
18
+ def references(items: object) -> set[str]:
19
+ if not isinstance(items, list):
20
+ return set()
21
+ result = set()
22
+ for item in items:
23
+ if isinstance(item, str):
24
+ result.add(item)
25
+ elif isinstance(item, dict) and isinstance(item.get("id"), str):
26
+ result.add(item["id"])
27
+ return result
28
+
29
+
30
+ def check(data: dict, stage: str) -> list[str]:
31
+ errors: list[str] = []
32
+ requirements = data.get("requirements")
33
+ if data.get("schema_version") != 1:
34
+ fail(errors, "schema_version must be 1")
35
+ if not isinstance(data.get("feature"), str) or not data["feature"].strip():
36
+ fail(errors, "feature is required")
37
+ if not isinstance(requirements, list) or not requirements:
38
+ return errors + ["requirements must be a non-empty array"]
39
+
40
+ active: set[str] = set()
41
+ known: set[str] = set()
42
+ for req in requirements:
43
+ if not isinstance(req, dict):
44
+ fail(errors, "every requirement must be an object")
45
+ continue
46
+ rid = req.get("id")
47
+ if not isinstance(rid, str) or not rid.startswith("REQ-"):
48
+ fail(errors, f"invalid requirement id: {rid!r}")
49
+ continue
50
+ known.add(rid)
51
+ if req.get("status", "confirmed") in {"confirmed", "approved"} and req.get("scope", "in") == "in":
52
+ active.add(rid)
53
+ dimensions = req.get("dimensions")
54
+ if not isinstance(dimensions, dict):
55
+ fail(errors, f"{rid}: dimensions is required")
56
+ else:
57
+ missing = [d for d in DIMENSIONS if not isinstance(dimensions.get(d), str) or not dimensions[d].strip()]
58
+ if missing:
59
+ fail(errors, f"{rid}: missing dimensions: {', '.join(missing)}")
60
+
61
+ if stage == "baseline":
62
+ return errors
63
+
64
+ designs = data.get("designs")
65
+ if not isinstance(designs, list) or not designs:
66
+ fail(errors, "designs must be a non-empty array after baseline")
67
+ covered_by_design = set()
68
+ for item in designs or []:
69
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str):
70
+ fail(errors, "every design needs an id")
71
+ continue
72
+ refs = references(item.get("requirements"))
73
+ unknown = refs - known
74
+ if unknown:
75
+ fail(errors, f"{item['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
76
+ covered_by_design |= refs
77
+ if item.get("kind") == "api":
78
+ contract = item.get("contract")
79
+ if not isinstance(contract, dict) or any(not contract.get(k) for k in ("request", "response", "errors", "behavior")):
80
+ fail(errors, f"{item['id']}: API design must include request, response, errors and behavior")
81
+ missing = active - covered_by_design
82
+ if missing:
83
+ fail(errors, "requirements missing design coverage: " + ", ".join(sorted(missing)))
84
+ if stage == "design":
85
+ return errors
86
+
87
+ tasks = data.get("tasks")
88
+ if not isinstance(tasks, list) or not tasks:
89
+ fail(errors, "tasks must be a non-empty array after design")
90
+ covered_by_task = set()
91
+ for task in tasks or []:
92
+ if not isinstance(task, dict) or not isinstance(task.get("id"), str):
93
+ fail(errors, "every task needs an id")
94
+ continue
95
+ refs = task.get("requirements")
96
+ if not isinstance(refs, list) or not refs:
97
+ fail(errors, f"{task['id']}: requirements is required")
98
+ else:
99
+ covered_by_task.update(refs)
100
+ unknown = set(refs) - known
101
+ if unknown:
102
+ fail(errors, f"{task['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
103
+ criteria = task.get("acceptance")
104
+ if not isinstance(criteria, dict):
105
+ fail(errors, f"{task['id']}: acceptance is required")
106
+ else:
107
+ missing = [d for d in DIMENSIONS if not isinstance(criteria.get(d), str) or not criteria[d].strip()]
108
+ if missing:
109
+ fail(errors, f"{task['id']}: missing acceptance dimensions: {', '.join(missing)}")
110
+ missing = active - covered_by_task
111
+ if missing:
112
+ fail(errors, "requirements missing task coverage: " + ", ".join(sorted(missing)))
113
+ if stage == "change-plan":
114
+ return errors
115
+
116
+ verification = data.get("verification")
117
+ if not isinstance(verification, list) or not verification:
118
+ fail(errors, "verification must be a non-empty array after change-plan")
119
+ covered_by_verification = set()
120
+ for item in verification or []:
121
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str):
122
+ fail(errors, "every verification item needs an id")
123
+ continue
124
+ refs = item.get("requirements")
125
+ if not isinstance(refs, list) or not refs:
126
+ fail(errors, f"{item['id']}: requirements is required")
127
+ else:
128
+ covered_by_verification.update(refs)
129
+ unknown = set(refs) - known
130
+ if unknown:
131
+ fail(errors, f"{item['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
132
+ if not isinstance(item.get("evidence"), str) or not item["evidence"].strip():
133
+ fail(errors, f"{item['id']}: evidence is required")
134
+ missing = active - covered_by_verification
135
+ if missing:
136
+ fail(errors, "requirements missing verification coverage: " + ", ".join(sorted(missing)))
137
+ return errors
138
+
139
+
140
+ def main() -> int:
141
+ parser = argparse.ArgumentParser()
142
+ parser.add_argument("--file", required=True)
143
+ parser.add_argument("--stage", choices=STAGES, required=True)
144
+ args = parser.parse_args()
145
+ try:
146
+ data = json.loads(Path(args.file).read_text(encoding="utf-8"))
147
+ except (OSError, json.JSONDecodeError) as exc:
148
+ print(f"TRACEABILITY_FAIL: cannot read manifest: {exc}", file=sys.stderr)
149
+ return 2
150
+ if not isinstance(data, dict):
151
+ print("TRACEABILITY_FAIL: manifest root must be an object", file=sys.stderr)
152
+ return 2
153
+ errors = check(data, args.stage)
154
+ result = {"status": "passed" if not errors else "failed", "stage": args.stage, "errors": errors}
155
+ print(json.dumps(result, ensure_ascii=False))
156
+ return 0 if not errors else 1
157
+
158
+
159
+ if __name__ == "__main__":
160
+ raise SystemExit(main())
@@ -4,6 +4,8 @@
4
4
  require "json"
5
5
  require "optparse"
6
6
  require "yaml"
7
+ require_relative "control_io"
8
+ require_relative "phase_ownership"
7
9
 
8
10
  module WorkflowStateValidator
9
11
  REQUIRED_STATE_FIELDS = %w[
@@ -18,12 +20,18 @@ module WorkflowStateValidator
18
20
 
19
21
  TERMINAL_ARTIFACT_STATUSES = %w[approved completed passed].freeze
20
22
  NON_TERMINAL_ARTIFACT_STATUSES = %w[planned generated pending_rerun stale failed].freeze
23
+ TERMINAL_WORKFLOW_STATUSES = %w[completed failed cancelled].freeze
21
24
 
22
25
  module_function
23
26
 
24
- def validate(root:, state_path:, state_override: nil, workflow_override: nil, artifact_registry_override: nil, strict_artifact_status: true, strict_registry_revision: true)
27
+ def validate(root:, state_path:, state_override: nil, workflow_override: nil, artifact_registry_override: nil, strict_artifact_status: true, strict_registry_revision: true, phase_owner_override: nil)
25
28
  root = File.expand_path(root)
26
- state_path = File.expand_path(state_path, root)
29
+ canonical_state_path, = DevflowControlIO.resolve_control_path(root, ".planning/STATE.md", :state)
30
+ unless File.expand_path(state_path, root) == canonical_state_path
31
+ issues = [issue("error", "invalid_control_path", "STATE.md path must be .planning/STATE.md")]
32
+ return result(false, state_path, nil, nil, issues)
33
+ end
34
+ state_path = canonical_state_path
27
35
  issues = []
28
36
 
29
37
  state = state_override || load_state(state_path, issues)
@@ -34,8 +42,14 @@ module WorkflowStateValidator
34
42
  end
35
43
  return result(false, state_path, nil, nil, issues) if issues.any? { |entry| entry["severity"] == "error" }
36
44
 
45
+ ownership = PhaseOwnership.validate_state(root: root, state: state, owner_override: phase_owner_override)
46
+ unless ownership["ok"]
47
+ issues << issue("error", ownership["reason"], ownership["message"], ownership.reject { |key, _| %w[ok reason message].include?(key) })
48
+ return result(false, state_path, nil, nil, issues)
49
+ end
50
+
37
51
  workflow_path = state["workflow_config"] || File.join(".planning", "workflows", "#{state.fetch("current_flow")}.yaml")
38
- abs_workflow_path = File.expand_path(workflow_path, root)
52
+ abs_workflow_path, workflow_path = DevflowControlIO.resolve_control_path(root, workflow_path, :workflow)
39
53
  workflow = workflow_override || load_yaml(abs_workflow_path, issues, "workflow_config")
40
54
  return result(false, state_path, workflow_path, nil, issues) unless workflow
41
55
 
@@ -103,7 +117,7 @@ module WorkflowStateValidator
103
117
 
104
118
  registry = registry_override
105
119
  unless registry
106
- abs_registry_path = File.expand_path(registry_path, root)
120
+ abs_registry_path, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
107
121
  unless File.exist?(abs_registry_path)
108
122
  issues << issue("error", "missing_artifact_registry", "Artifact registry does not exist", path: registry_path)
109
123
  return {}
@@ -121,7 +135,7 @@ module WorkflowStateValidator
121
135
  )
122
136
  end
123
137
  Array(registry["artifacts"]).to_h { |artifact| [artifact.fetch("path"), artifact] }
124
- rescue ArgumentError, TypeError, Psych::SyntaxError => e
138
+ rescue ArgumentError, TypeError, Psych::SyntaxError, DevflowControlIO::InvalidControlPath => e
125
139
  issues << issue("error", "invalid_artifact_registry", e.message, path: registry_path)
126
140
  {}
127
141
  end
@@ -145,8 +159,10 @@ module WorkflowStateValidator
145
159
  unknown.each do |step_id|
146
160
  issues << issue("error", "unknown_tracked_step", "Tracked step is not defined in the active workflow", step: step_id)
147
161
  end
148
- missing.each do |step_id|
149
- issues << issue("error", "missing_tracked_step", "Workflow step is missing from both completed_steps and pending_steps", step: step_id)
162
+ unless %w[failed cancelled].include?(state["status"])
163
+ missing.each do |step_id|
164
+ issues << issue("error", "missing_tracked_step", "Workflow step is missing from both completed_steps and pending_steps", step: step_id)
165
+ end
150
166
  end
151
167
 
152
168
  current_step = state["current_step"]
@@ -162,6 +178,10 @@ module WorkflowStateValidator
162
178
  missing_completed.each do |step_id|
163
179
  issues << issue("error", "completed_status_missing_step", "Workflow status is completed but a workflow step is not completed", step: step_id)
164
180
  end
181
+ elsif %w[failed cancelled].include?(state["status"])
182
+ if pending_ids.any?
183
+ issues << issue("error", "terminal_with_pending_steps", "Terminal workflow status must not retain pending steps", status: state["status"])
184
+ end
165
185
  elsif pending_ids.empty?
166
186
  issues << issue("error", "non_completed_without_pending_steps", "Workflow has no pending steps but status is not completed")
167
187
  end
@@ -304,6 +324,7 @@ module WorkflowStateValidator
304
324
  validate_frontend_tech_spec_gate(issues, step, data)
305
325
  validate_api_supplement_gate(issues, step, data)
306
326
  validate_integration_merge_gate(issues, step, data)
327
+ validate_change_plan_gate(issues, step, data)
307
328
  validate_verification_gate(issues, step, data)
308
329
  rescue JSON::ParserError => e
309
330
  issues << issue("error", "invalid_meta_json", e.message, step: step["id"], path: path)
@@ -403,6 +424,10 @@ module WorkflowStateValidator
403
424
 
404
425
  issues << issue("error", "baseline_blocking_counter_nonzero", "Requirement baseline has a non-zero blocking counter", step: step["id"], field: field, count: data[field])
405
426
  end
427
+
428
+ if data["traceability_status"] != "passed"
429
+ issues << issue("error", "baseline_traceability_not_passed", "Requirement traceability must pass before technical design", step: step["id"], status: data["traceability_status"])
430
+ end
406
431
  end
407
432
 
408
433
  def validate_api_contract_gate(issues, step, data)
@@ -416,6 +441,10 @@ module WorkflowStateValidator
416
441
  issues << issue("error", "api_contract_pending_items", "API contract has unresolved pending contract items", step: step["id"], count: data["pending_contract_count"])
417
442
  end
418
443
 
444
+ if data["traceability_status"] != "passed"
445
+ issues << issue("error", "api_contract_traceability_not_passed", "API contract must retain complete requirement traceability", step: step["id"], status: data["traceability_status"])
446
+ end
447
+
419
448
  api_required = data["api_required"]
420
449
  endpoint_count = data["endpoint_count"].to_i
421
450
  if api_required == false && endpoint_count.positive?
@@ -495,6 +524,10 @@ module WorkflowStateValidator
495
524
  issues << issue("error", "merge_gate_not_passed", "Integration merge gate status is not passed", step: step["id"], status: data["merge_gate_status"])
496
525
  end
497
526
 
527
+ if data["traceability_status"] != "passed"
528
+ issues << issue("error", "integration_traceability_not_passed", "Integration gate requires complete requirement-to-design traceability", step: step["id"], status: data["traceability_status"])
529
+ end
530
+
498
531
  %w[
499
532
  contract_backend_mismatch_count
500
533
  contract_frontend_mismatch_count
@@ -507,6 +540,20 @@ module WorkflowStateValidator
507
540
  end
508
541
  end
509
542
 
543
+ def validate_change_plan_gate(issues, step, data)
544
+ return unless step["id"] == "change-plan"
545
+
546
+ if data["traceability_status"] != "passed"
547
+ issues << issue("error", "change_plan_traceability_not_passed", "Change plan requires complete requirement-to-task traceability", step: step["id"], status: data["traceability_status"])
548
+ end
549
+
550
+ %w[uncovered_requirement_count orphan_task_count].each do |field|
551
+ next if data[field].to_i.zero?
552
+
553
+ issues << issue("error", "change_plan_traceability_counter_nonzero", "Change plan has unresolved traceability coverage", step: step["id"], field: field, count: data[field])
554
+ end
555
+ end
556
+
510
557
  def validate_verification_gate(issues, step, data)
511
558
  return unless step["id"] == "verify"
512
559
 
@@ -589,7 +636,12 @@ if __FILE__ == $PROGRAM_NAME
589
636
  end.parse!
590
637
 
591
638
  root = Dir.pwd
592
- state_path = File.expand_path(options[:state], root)
639
+ begin
640
+ state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
641
+ rescue DevflowControlIO::InvalidControlPath => error
642
+ puts JSON.pretty_generate({ "ok" => false, "errors" => [{ "code" => "invalid_control_path", "message" => error.message }] })
643
+ exit 1
644
+ end
593
645
  validation = WorkflowStateValidator.validate(
594
646
  root: root,
595
647
  state_path: state_path,