@guanlin4924/devflow-pi 0.1.38 → 0.1.40

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 +178 -8
  3. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +283 -1
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +31 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +64 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_result.rb +40 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +157 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -9
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +222 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_coverage.rb +173 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +14 -8
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +131 -5
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +148 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +29 -0
  18. package/src/constants.ts +1 -1
  19. package/src/runtime.ts +8 -2
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+ require "digest"
6
+ require "fileutils"
7
+ require "json"
8
+ require "optparse"
9
+ require "securerandom"
10
+ require "time"
11
+ require "yaml"
12
+ require_relative "control_io"
13
+
14
+ # Durable scheduler state for one workflow parallel window. This file owns
15
+ # dispatch state only; artifacts/index.yaml owns artifact content state and
16
+ # STATE.md owns the workflow snapshot/current pointer.
17
+ module ParallelRun
18
+ SCHEMA_VERSION = 1
19
+ TERMINAL_BRANCH_STATUSES = %w[succeeded failed aborted].freeze
20
+
21
+ module_function
22
+
23
+ def safe_feature(feature)
24
+ value = feature.to_s
25
+ raise ArgumentError, "feature is required" if value.empty?
26
+ raise ArgumentError, "feature contains path separators" if value.include?("/") || value.include?("\\") || value == "." || value == ".."
27
+
28
+ value
29
+ end
30
+
31
+ def path(root, feature)
32
+ File.join(root, ".planning", "parallel", safe_feature(feature), "branches.yaml")
33
+ end
34
+
35
+ def load(root, feature)
36
+ target = path(root, feature)
37
+ return nil unless File.file?(target)
38
+
39
+ value = YAML.safe_load(File.read(target), permitted_classes: [Date, Time], aliases: false)
40
+ raise ArgumentError, "parallel run record must be a mapping" unless value.is_a?(Hash)
41
+ value
42
+ end
43
+
44
+ def write(root, feature, record)
45
+ raise ArgumentError, "parallel run record must be a mapping" unless record.is_a?(Hash)
46
+
47
+ DevflowControlIO.atomic_write(path(root, feature), record.to_yaml)
48
+ record
49
+ end
50
+
51
+ def state_front_matter(markdown)
52
+ match = markdown.match(/\A---\n(.*?)\n---\n/m)
53
+ match && match[1]
54
+ end
55
+
56
+ def attach_state_ref(root, state_path, run_id, expected_revision)
57
+ absolute, = DevflowControlIO.resolve_control_path(root, state_path, :state)
58
+ original = File.read(absolute)
59
+ front = state_front_matter(original)
60
+ raise ArgumentError, "STATE.md has no YAML front matter" unless front
61
+
62
+ state = YAML.safe_load(front, permitted_classes: [], aliases: false)
63
+ raise ArgumentError, "STATE.md must contain a mapping" unless state.is_a?(Hash)
64
+ actual = Integer(state.fetch("revision", 0))
65
+ raise ArgumentError, "STATE revision conflict" unless actual == Integer(expected_revision)
66
+ raise ArgumentError, "STATE already references a parallel run" if state["parallel_run_ref"]
67
+
68
+ state["parallel_run_ref"] = run_id
69
+ state["revision"] = actual + 1
70
+ body = original.sub(/\A---\n.*?\n---\n/m, "---\n#{state.to_yaml.sub(/\A---\n/, "")}---\n")
71
+ DevflowControlIO.atomic_write(absolute, body)
72
+ state
73
+ end
74
+
75
+ def prepare(root:, feature:, workflow_window:, state_revision:, branches:, task_ids: {}, state_path: ".planning/STATE.md")
76
+ lock = DevflowControlIO.acquire_project_lock(root)
77
+ begin
78
+ raise ArgumentError, "parallel run already exists" if load(root, feature)
79
+
80
+ run_id = "PR-#{SecureRandom.uuid}"
81
+ branch_records = Array(branches).map do |branch|
82
+ value = branch.is_a?(Hash) ? branch : { "branch_id" => branch }
83
+ workflow_step = value["workflow_step"].to_s
84
+ track = value["track"].to_s
85
+ raise ArgumentError, "workflow_step is required" if workflow_step.empty?
86
+ raise ArgumentError, "track is required" if track.empty?
87
+ branch_id = value["branch_id"].to_s
88
+ branch_id = "#{run_id}-#{track.sub(/\Atrack_/, "")}-#{workflow_step}" if branch_id.empty?
89
+
90
+ value.merge(
91
+ "branch_id" => branch_id,
92
+ "workflow_step" => workflow_step,
93
+ "track" => track,
94
+ "attempt" => 0,
95
+ "branch_revision" => 0,
96
+ "status" => "pending",
97
+ "task_id" => task_ids[branch_id],
98
+ "previous_branch_id" => nil,
99
+ "history" => [],
100
+ ).compact
101
+ end
102
+ record = {
103
+ "schema_version" => SCHEMA_VERSION,
104
+ "run_id" => run_id,
105
+ "workflow_window" => workflow_window,
106
+ "bound_state_revision" => Integer(state_revision),
107
+ "run_revision" => 0,
108
+ "status" => "pending",
109
+ "created_at" => Time.now.utc.iso8601,
110
+ "branches" => branch_records,
111
+ }
112
+ write(root, feature, record)
113
+ updated_state = attach_state_ref(root, state_path, run_id, state_revision)
114
+ record["bound_state_revision"] = Integer(updated_state.fetch("revision"))
115
+ record["branches"].each { |branch| branch["expected_state_revision"] = record["bound_state_revision"] }
116
+ write(root, feature, record)
117
+ { "ok" => true, "run" => record }
118
+ ensure
119
+ DevflowControlIO.release_lock(lock)
120
+ end
121
+ end
122
+
123
+ def update_branch(root:, feature:, branch_id:, expected_revision:, status:, task_id: nil, evidence_path: nil, output_fingerprint: nil, error: nil, lock: nil)
124
+ owned_lock = lock.nil?
125
+ lock ||= DevflowControlIO.acquire_project_lock(root)
126
+ begin
127
+ record = load(root, feature) || raise(ArgumentError, "parallel run not found")
128
+ actual = Integer(record.fetch("run_revision", 0))
129
+ raise ArgumentError, "parallel run revision conflict" unless actual == Integer(expected_revision)
130
+
131
+ branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
132
+ raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
133
+ raise ArgumentError, "invalid branch status: #{status}" unless (TERMINAL_BRANCH_STATUSES + %w[pending running]).include?(status)
134
+ raise ArgumentError, "terminal branch must retry before update" if TERMINAL_BRANCH_STATUSES.include?(branch["status"])
135
+
136
+ branch["status"] = status
137
+ branch["branch_revision"] = Integer(branch.fetch("branch_revision", 0)) + 1
138
+ branch["task_id"] = task_id if task_id
139
+ branch["evidence_path"] = evidence_path if evidence_path
140
+ branch["output_fingerprint"] = output_fingerprint if output_fingerprint
141
+ branch["error"] = error if error
142
+ record["run_revision"] = actual + 1
143
+ record["status"] = "failed" if status == "failed"
144
+ record["status"] = "running" if status == "running" && record["status"] == "pending"
145
+ record["status"] = "completed" if Array(record["branches"]).all? { |item| item["status"] == "succeeded" }
146
+ record["updated_at"] = Time.now.utc.iso8601
147
+ write(root, feature, record)
148
+ { "ok" => true, "run" => record }
149
+ ensure
150
+ DevflowControlIO.release_lock(lock) if owned_lock
151
+ end
152
+ end
153
+
154
+ def retry_branch(root:, feature:, branch_id:, expected_revision:, lock: nil)
155
+ owned_lock = lock.nil?
156
+ lock ||= DevflowControlIO.acquire_project_lock(root)
157
+ begin
158
+ record = load(root, feature) || raise(ArgumentError, "parallel run not found")
159
+ raise ArgumentError, "parallel run revision conflict" unless Integer(record.fetch("run_revision", 0)) == Integer(expected_revision)
160
+ branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
161
+ raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
162
+ raise ArgumentError, "only failed/aborted branch can retry" unless %w[failed aborted].include?(branch["status"])
163
+
164
+ previous_id = branch["branch_id"]
165
+ branch["history"] ||= []
166
+ branch["history"] << branch.slice("branch_id", "attempt", "branch_revision", "status", "task_id", "evidence_path")
167
+ branch["attempt"] = Integer(branch.fetch("attempt", 0)) + 1
168
+ branch["branch_revision"] = 0
169
+ branch["previous_branch_id"] = previous_id
170
+ branch["status"] = "pending"
171
+ branch.delete("error")
172
+ record["status"] = "running"
173
+ record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
174
+ record["updated_at"] = Time.now.utc.iso8601
175
+ write(root, feature, record)
176
+ { "ok" => true, "run" => record }
177
+ ensure
178
+ DevflowControlIO.release_lock(lock) if owned_lock
179
+ end
180
+ end
181
+ end
182
+
183
+ if $PROGRAM_NAME == __FILE__
184
+ begin
185
+ options = { action: nil, feature: nil, state_revision: nil, run_revision: nil, branch_id: nil, status: nil, workflow_window: nil, branches: [], state_path: ".planning/STATE.md", json: false }
186
+ OptionParser.new do |opts|
187
+ opts.on("--action ACTION", %w[inspect prepare update retry]) { |value| options[:action] = value }
188
+ opts.on("--feature FEATURE") { |value| options[:feature] = value }
189
+ opts.on("--state-revision REV", Integer) { |value| options[:state_revision] = value }
190
+ opts.on("--run-revision REV", Integer) { |value| options[:run_revision] = value }
191
+ opts.on("--branch-id ID") { |value| options[:branch_id] = value }
192
+ opts.on("--status STATUS") { |value| options[:status] = value }
193
+ opts.on("--workflow-window NAME") { |value| options[:workflow_window] = value }
194
+ opts.on("--state PATH") { |value| options[:state_path] = value }
195
+ opts.on("--branch STEP:TRACK", "Register a branch as workflow_step:track") do |value|
196
+ workflow_step, track = value.split(":", 2)
197
+ raise ArgumentError, "--branch must be WORKFLOW_STEP:TRACK" if workflow_step.to_s.empty? || track.to_s.empty?
198
+ options[:branches] << { "workflow_step" => workflow_step, "track" => track }
199
+ end
200
+ opts.on("--json") { options[:json] = true }
201
+ end.parse!
202
+
203
+ raise ArgumentError, "--feature is required" unless options[:feature]
204
+ result = case options[:action]
205
+ when "inspect"
206
+ record = ParallelRun.load(Dir.pwd, options[:feature])
207
+ record ? { "ok" => true, "run" => record } : { "ok" => false, "reason" => "parallel_run_not_found" }
208
+ when "prepare"
209
+ ParallelRun.prepare(root: Dir.pwd, feature: options[:feature], workflow_window: options[:workflow_window], state_revision: options[:state_revision], branches: options[:branches], state_path: options[:state_path])
210
+ when "update"
211
+ ParallelRun.update_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision], status: options[:status])
212
+ when "retry"
213
+ ParallelRun.retry_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision])
214
+ else
215
+ raise ArgumentError, "--action must be inspect, prepare, update, or retry"
216
+ end
217
+ puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
218
+ rescue StandardError => e
219
+ warn e.message
220
+ exit 1
221
+ end
222
+ end
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Canonical wayfinder quality checker. It validates references and structured
5
+ # coverage only; AI-provided equivalence labels are never trusted.
6
+ require "digest"
7
+ require "json"
8
+ require "optparse"
9
+ require "yaml"
10
+ require_relative "requirement_graph"
11
+
12
+ module RequirementCoverage
13
+ module_function
14
+
15
+ def validate(root:, feature:)
16
+ directory = RequirementGraph.feature_dir(root, feature)
17
+ errors = []
18
+ source = load(File.join(directory, "source_units.yaml"), errors, "SOURCE_UNITS_MISSING")
19
+ coverage = load(File.join(directory, "coverage.yaml"), errors, "COVERAGE_ARTIFACT_MISSING")
20
+ return result(errors, {}, {}) unless source && coverage
21
+ validate_source(source, errors)
22
+ validate_coverage(coverage, errors)
23
+ return result(errors, {}, {}) unless errors.empty?
24
+
25
+ units = Array(source["units"])
26
+ unit_by_id = units.to_h { |unit| [unit["stable_unit_id"].to_s, unit] }
27
+ assertions = units.flat_map { |unit| Array(unit["required_assertions"]) }
28
+ assertion_by_id = assertions.to_h { |assertion| [assertion["assertion_id"].to_s, assertion] }
29
+ claims = Array(coverage["claims"])
30
+ claim_ids = {}
31
+ covered = {}
32
+ uncertain = {}
33
+ approval_required = {}
34
+
35
+ claims.each do |claim|
36
+ id = claim["claim_id"].to_s
37
+ duplicate(errors, claim_ids, id, "DUPLICATE_CLAIM_ID")
38
+ unit_id = claim["source_unit_id"].to_s
39
+ unless unit_by_id.key?(unit_id)
40
+ add(errors, "CLAIM_WITHOUT_UNIT", "claim references an unknown source unit", claim_id: id)
41
+ next
42
+ end
43
+ Array(claim["assertion_ids"]).each do |assertion_id|
44
+ assertion = assertion_by_id[assertion_id.to_s]
45
+ unless assertion && assertion["source_unit_id"].to_s == unit_id
46
+ add(errors, "ASSERTION_REF_NOT_FOUND", "claim references an unknown assertion", claim_id: id, assertion_id: assertion_id)
47
+ next
48
+ end
49
+ relation = compare(assertion, claim["inherited_constraints"])
50
+ case relation
51
+ when "equivalent" then covered[assertion_id.to_s] = true
52
+ when "uncertain" then uncertain[assertion_id.to_s] = true
53
+ when "scope_widened", "constraint_tightened"
54
+ approval = claim["approval"]
55
+ expected_content_fingerprint = sha(canonical(claim["inherited_constraints"]))
56
+ if approval.is_a?(Hash) && approval["approval_id"].to_s != "" && approval["claim_id"].to_s == id && approval["assertion_id"].to_s == assertion_id.to_s && approval["relation"].to_s == relation && approval["content_fingerprint"].to_s == expected_content_fingerprint && approval["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
57
+ covered[assertion_id.to_s] = true
58
+ else
59
+ approval_required[assertion_id.to_s] = true
60
+ end
61
+ else
62
+ add(errors, relation == "under_coverage" ? "ASSERTION_UNCOVERED" : "CONTRADICTORY_CLAIM", "claim is not an equivalent coverage", claim_id: id, assertion_id: assertion_id, relation: relation)
63
+ end
64
+ end
65
+ end
66
+
67
+ disposition_by_id = {}
68
+ Array(coverage["units"]).each do |entry|
69
+ unit_id = entry["unit_id"].to_s
70
+ duplicate(errors, disposition_by_id, unit_id, "DUPLICATE_COVERAGE")
71
+ add(errors, "UNIT_REF_NOT_FOUND", "coverage references an unknown unit", unit_id: unit_id) unless unit_by_id.key?(unit_id)
72
+ disposition = entry["disposition"]
73
+ unless disposition.is_a?(Hash) && %w[active deferred out_of_scope merged].include?(disposition["kind"].to_s)
74
+ add(errors, "DISPOSITION_SCHEMA_INVALID", "invalid unit disposition", unit_id: unit_id)
75
+ next
76
+ end
77
+ if %w[deferred out_of_scope].include?(disposition["kind"].to_s)
78
+ approval = disposition["approval"]
79
+ unless approval.is_a?(Hash) && approval["approval_id"].to_s != "" && approval["unit_id"].to_s == unit_id && approval["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
80
+ add(errors, "DISPOSITION_APPROVAL_MISSING", "deferred/out_of_scope requires a current unit approval", unit_id: unit_id)
81
+ end
82
+ end
83
+ end
84
+ unit_by_id.each_key { |id| add(errors, "UNIT_DISPOSITION_MISSING", "source unit has no disposition", unit_id: id) unless disposition_by_id.key?(id) }
85
+ active = disposition_by_id.select { |_id, entry| entry.dig("disposition", "kind").to_s == "active" }.keys
86
+ active.each do |unit_id|
87
+ Array(unit_by_id[unit_id]["required_assertions"]).each do |assertion|
88
+ id = assertion["assertion_id"].to_s
89
+ next if covered[id] || uncertain[id] || approval_required[id]
90
+ add(errors, "ASSERTION_UNCOVERED", "active assertion has no valid claim", unit_id: unit_id, assertion_id: id)
91
+ end
92
+ end
93
+ counts = { "unit_count" => units.length, "assertion_count" => assertions.length, "claim_count" => claims.length, "covered_assertion_count" => covered.length, "uncertain_assertion_count" => uncertain.length, "approval_required_assertion_count" => approval_required.length }
94
+ result(errors, counts, (uncertain.any? || approval_required.any?) ? "await_user" : nil)
95
+ rescue JSON::ParserError, Psych::Exception, ArgumentError => e
96
+ result([{ "code" => "COVERAGE_SCHEMA_INVALID", "message" => e.message }], {}, {})
97
+ end
98
+
99
+ def load(path, errors, code)
100
+ unless File.file?(path)
101
+ add(errors, code, "required coverage artifact is missing", path: path)
102
+ return nil
103
+ end
104
+ value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
105
+ raise ArgumentError, "coverage artifact must be a mapping" unless value.is_a?(Hash)
106
+ value
107
+ end
108
+
109
+ def validate_source(source, errors)
110
+ add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "source_units schema_version must be 1") unless source["schema_version"].to_i == 1
111
+ add(errors, "SOURCE_UNITS_NOT_CORE_GENERATED", "source_units must be produced by Core") unless source["produced_by"].to_s == "core.source_unitizer/v1"
112
+ add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "source_document_fingerprint is required") if source["source_document_fingerprint"].to_s.empty?
113
+ add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "units must be an array") unless source["units"].is_a?(Array)
114
+ Array(source["units"]).each do |unit|
115
+ add(errors, "UNIT_SCHEMA_INVALID", "stable_unit_id is required") if unit["stable_unit_id"].to_s.empty?
116
+ add(errors, "SOURCE_UNIT_REUSED_STALE", "unit source fingerprint mismatch", unit_id: unit["stable_unit_id"]) unless unit["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
117
+ add(errors, "EXCERPT_MODIFIED", "excerpt fingerprint mismatch", unit_id: unit["stable_unit_id"]) if unit["excerpt_fingerprint"] && sha(unit["excerpt"]) != unit["excerpt_fingerprint"].to_s
118
+ end
119
+ end
120
+
121
+ def validate_coverage(coverage, errors)
122
+ add(errors, "COVERAGE_SCHEMA_INVALID", "coverage schema_version must be 1") unless coverage["schema_version"].to_i == 1
123
+ %w[claims units].each { |key| add(errors, "COVERAGE_SCHEMA_INVALID", "#{key} must be an array") unless coverage[key].is_a?(Array) }
124
+ end
125
+
126
+ def compare(assertion, inherited)
127
+ return "uncertain" unless inherited.is_a?(Hash)
128
+ expected = assertion.select { |key, _| %w[type polarity modality predicate condition role_scope order_constraint data_constraint exception].include?(key) }
129
+ actual = inherited.select { |key, _| expected.key?(key) }
130
+ return "equivalent" if canonical(expected) == canonical(actual)
131
+ return "under_coverage" if expected["polarity"] && actual["polarity"] && expected["polarity"] != actual["polarity"]
132
+ return "under_coverage" if expected["modality"] == "must" && actual["modality"] == "should"
133
+ if expected["role_scope"].is_a?(Hash) && actual["role_scope"].is_a?(Hash)
134
+ expected_include = Array(expected["role_scope"]["include"]); actual_include = Array(actual["role_scope"]["include"])
135
+ return "scope_widened" if (actual_include - expected_include).any?
136
+ return "under_coverage" if (expected_include - actual_include).any?
137
+ end
138
+ "uncertain"
139
+ end
140
+
141
+ def canonical(value)
142
+ JSON.generate(value.is_a?(Hash) ? value.keys.sort.to_h { |key| [key, canonical_value(value[key])] } : value)
143
+ end
144
+ def canonical_value(value)
145
+ value.is_a?(Hash) ? value.keys.sort.to_h { |key| [key, canonical_value(value[key])] } : value.is_a?(Array) ? value.map { |item| canonical_value(item) } : value
146
+ end
147
+ def sha(value)
148
+ Digest::SHA256.hexdigest(value.to_s)
149
+ end
150
+ def duplicate(errors, seen, id, code)
151
+ add(errors, code, "duplicate identifier", id: id) if seen.key?(id)
152
+ seen[id] = true
153
+ end
154
+ def add(errors, code, message, **extra)
155
+ errors << { "code" => code, "message" => message }.merge(extra)
156
+ end
157
+ def result(errors, counts, forced_verdict = nil)
158
+ verdict = if errors.any? then "block_unsafe" elsif forced_verdict then forced_verdict else "allow" end
159
+ { "ok" => verdict == "allow", "verdict" => verdict, "errors" => errors, "warnings" => [], "counts" => counts, "evidence" => { "checker" => "requirement-coverage-check" } }
160
+ end
161
+ end
162
+
163
+ if $PROGRAM_NAME == __FILE__
164
+ options = {}
165
+ OptionParser.new do |opts|
166
+ opts.on("--root PATH") { |value| options[:root] = value }
167
+ opts.on("--feature NAME") { |value| options[:feature] = value }
168
+ end.parse!
169
+ abort("--root and --feature are required") unless options[:root] && options[:feature]
170
+ payload = RequirementCoverage.validate(root: options[:root], feature: options[:feature])
171
+ puts JSON.pretty_generate(payload)
172
+ exit(payload["verdict"] == "allow" ? 0 : 1)
173
+ end
@@ -63,6 +63,10 @@ module RequirementGraph
63
63
  JSON.generate(value)
64
64
  end
65
65
 
66
+ def deep_clone(value)
67
+ JSON.parse(JSON.generate(value))
68
+ end
69
+
66
70
  def fingerprint(value)
67
71
  Digest::SHA256.hexdigest(canonical_json(value))
68
72
  end
@@ -1000,20 +1004,22 @@ if __FILE__ == $PROGRAM_NAME
1000
1004
  { "ok" => true, "path" => relative, "context" => ctx }
1001
1005
  end
1002
1006
  when "propose"
1003
- raise ArgumentError, "--write is required" unless options[:write]
1004
1007
  raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
1005
1008
  if graph["revision"] != options[:expected_revision]
1006
1009
  { "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
1007
1010
  else
1008
1011
  delta = JSON.parse(options[:delta_json].to_s)
1009
1012
  allowed_kinds = options[:allowed_kinds]&.split(",")&.map(&:strip)
1010
- applied = RequirementGraph.proposal(graph, delta, allowed_kinds)
1011
- graph["revision"] += 1
1012
- stale = Array(graph["stale"]).any?
1013
- RequirementGraph.write_views!(root, graph, stale: stale)
1014
- RequirementGraph.append_audit!(root, graph, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
1015
- DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
1016
- { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
1013
+ candidate = RequirementGraph.deep_clone(graph)
1014
+ applied = RequirementGraph.proposal(candidate, delta, allowed_kinds)
1015
+ candidate["revision"] += 1
1016
+ stale = Array(candidate["stale"]).any?
1017
+ if options[:write]
1018
+ RequirementGraph.write_views!(root, candidate, stale: stale)
1019
+ RequirementGraph.append_audit!(root, candidate, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
1020
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(candidate) + "\n")
1021
+ end
1022
+ { "ok" => true, "path" => relative, "written" => options[:write], "revision" => options[:write] ? candidate["revision"] : graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(candidate), "checkers" => RequirementGraph.checker_results(candidate), "views" => candidate["views"] }
1017
1023
  end
1018
1024
  when "import-test-cases"
1019
1025
  raise ArgumentError, "--write is required" unless options[:write]
@@ -9,6 +9,10 @@ require_relative "control_io"
9
9
  require_relative "phase_ownership"
10
10
  require_relative "requirement_graph"
11
11
  require_relative "architecture_feasibility"
12
+ require_relative "checker_registry"
13
+ require_relative "requirement_coverage"
14
+ require_relative "workflow_gate_contract"
15
+ require_relative "parallel_run"
12
16
 
13
17
  module WorkflowStateValidator
14
18
  REQUIRED_STATE_FIELDS = %w[
@@ -68,6 +72,8 @@ module WorkflowStateValidator
68
72
  )
69
73
 
70
74
  steps = Array(workflow["steps"])
75
+ validate_checker_registry(issues, workflow)
76
+ validate_workflow_gate_contract(issues, workflow)
71
77
  step_by_id = steps.to_h { |step| [step.fetch("id"), step] }
72
78
  step_ids = steps.map { |step| step.fetch("id") }
73
79
  completed_ids = Array(state["completed_steps"])
@@ -75,9 +81,13 @@ module WorkflowStateValidator
75
81
  tracked_ids = completed_ids + pending_ids
76
82
 
77
83
  validate_step_tracking(issues, state, step_ids, completed_ids, pending_ids, tracked_ids)
78
- validate_track_scope(issues, state, completed_ids)
84
+ if state["parallel_run_ref"]
85
+ validate_parallel_run(issues, root, state, workflow, step_by_id)
86
+ else
87
+ validate_track_scope(issues, state, completed_ids)
88
+ end
79
89
  validate_dependencies(issues, step_by_id, completed_ids)
80
- validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status)
90
+ validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
81
91
  validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
82
92
  validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
83
93
  graph_gate = workflow.dig("governance", "requirement_graph_gate").to_s
@@ -264,13 +274,113 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
264
274
  end
265
275
  end
266
276
 
267
- def validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status)
277
+ def validate_parallel_run(issues, root, state, workflow, step_by_id)
278
+ feature = state["current_feature"].to_s
279
+ run = ParallelRun.load(root, feature)
280
+ unless run
281
+ issues << issue("error", "parallel_run_missing", "parallel_run_ref does not resolve to a run record", feature: feature)
282
+ return
283
+ end
284
+ if run["run_id"].to_s != state["parallel_run_ref"].to_s
285
+ issues << issue("error", "parallel_run_ref_mismatch", "parallel_run_ref does not match the run record", reference: state["parallel_run_ref"], run_id: run["run_id"])
286
+ end
287
+ begin
288
+ bound_revision = Integer(run.fetch("bound_state_revision"))
289
+ issues << issue("error", "parallel_state_revision_mismatch", "parallel run is bound to a different STATE revision", state_revision: state["revision"], bound_state_revision: bound_revision) unless bound_revision == Integer(state.fetch("revision"))
290
+ rescue ArgumentError, TypeError, KeyError
291
+ issues << issue("error", "parallel_run_revision_invalid", "parallel run bound_state_revision is invalid")
292
+ end
293
+
294
+ window = Array(workflow["parallel_windows"]).find { |candidate| candidate.is_a?(Hash) && candidate["id"].to_s == run["workflow_window"].to_s }
295
+ unless window
296
+ issues << issue("error", "parallel_window_not_found", "parallel run references an unknown workflow window", window: run["workflow_window"])
297
+ return
298
+ end
299
+ join_step = window["join_step"].to_s
300
+ if state["current_step"].to_s != join_step
301
+ issues << issue("error", "parallel_current_step_invalid", "parallel state current_step must remain the window join step", current_step: state["current_step"], join_step: join_step)
302
+ end
303
+
304
+ branch_steps = window["branches"].is_a?(Hash) ? window["branches"].each_with_object({}) do |(track, steps), result|
305
+ Array(steps).each { |step| result[step.to_s] = track.to_s }
306
+ end : {}
307
+ seen_ids = {}
308
+ Array(run["branches"]).each do |branch|
309
+ unless branch.is_a?(Hash)
310
+ issues << issue("error", "parallel_branch_invalid", "parallel run branch must be a mapping")
311
+ next
312
+ end
313
+ branch_id = branch["branch_id"].to_s
314
+ step_id = branch["workflow_step"].to_s
315
+ track = branch["track"].to_s
316
+ issues << issue("error", "parallel_branch_id_missing", "parallel branch_id is required") if branch_id.empty?
317
+ issues << issue("error", "parallel_branch_duplicate_id", "parallel branch_id is duplicated", branch_id: branch_id) if seen_ids[branch_id]
318
+ seen_ids[branch_id] = true
319
+ issues << issue("error", "parallel_branch_step_missing", "parallel branch workflow_step is required", branch_id: branch_id) if step_id.empty?
320
+ issues << issue("error", "parallel_branch_step_unknown", "parallel branch step is not in the window", branch_id: branch_id, step: step_id) unless branch_steps.key?(step_id)
321
+ issues << issue("error", "parallel_branch_track_mismatch", "parallel branch track does not match the workflow window", branch_id: branch_id, track: track, expected_track: branch_steps[step_id]) if branch_steps[step_id] && track.sub(/\Atrack_/, "").upcase != branch_steps[step_id].sub(/\Atrack_/, "").upcase
322
+ begin
323
+ attempt = Integer(branch.fetch("attempt"))
324
+ branch_revision = Integer(branch.fetch("branch_revision"))
325
+ issues << issue("error", "parallel_branch_attempt_invalid", "parallel branch attempt must be non-negative", branch_id: branch_id) if attempt.negative?
326
+ issues << issue("error", "parallel_branch_revision_invalid", "parallel branch revision must be non-negative", branch_id: branch_id) if branch_revision.negative?
327
+ rescue ArgumentError, TypeError, KeyError
328
+ issues << issue("error", "parallel_branch_version_invalid", "parallel branch attempt and branch_revision are required integers", branch_id: branch_id)
329
+ end
330
+ expected_state_revision = branch["expected_state_revision"]
331
+ if expected_state_revision && expected_state_revision.to_i != state["revision"].to_i
332
+ issues << issue("error", "parallel_branch_state_revision_mismatch", "parallel branch is bound to a different STATE revision", branch_id: branch_id)
333
+ end
334
+ if step_id && state["completed_steps"].to_a.map(&:to_s).include?(step_id)
335
+ issues << issue("error", "parallel_step_completed_before_join", "parallel branch step must not enter completed_steps before join", branch_id: branch_id, step: step_id)
336
+ end
337
+ end
338
+ declared_steps = branch_steps.keys
339
+ actual_steps = Array(run["branches"]).select { |branch| branch.is_a?(Hash) }.map { |branch| branch["workflow_step"].to_s }
340
+ duplicate_steps = actual_steps.each_with_object(Hash.new(0)) { |step, counts| counts[step] += 1 }.select { |_, count| count > 1 }.keys
341
+ duplicate_steps.each { |step| issues << issue("error", "parallel_branch_step_duplicated", "parallel run contains duplicate workflow_step records", step: step) }
342
+ (declared_steps - actual_steps).each { |step| issues << issue("error", "parallel_branch_record_missing", "workflow branch step has no persisted branch record", step: step) }
343
+ rescue ArgumentError, TypeError, Psych::Exception => error
344
+ issues << issue("error", "parallel_run_invalid", "parallel run record is invalid", detail: error.message)
345
+ end
346
+
347
+ def validate_checker_registry(issues, workflow)
348
+ declared = workflow.dig("governance", "step_checkers")
349
+ return unless declared.is_a?(Hash)
350
+
351
+ declared.each do |step_id, checker_ids|
352
+ Array(checker_ids).each do |checker_id|
353
+ next if CheckerRegistry.known?(checker_id)
354
+
355
+ issues << issue(
356
+ "error",
357
+ "unknown_checker_id",
358
+ "Workflow references a checker that is not in the canonical checker registry",
359
+ step: step_id,
360
+ checker_id: checker_id
361
+ )
362
+ end
363
+ end
364
+ end
365
+
366
+ def validate_workflow_gate_contract(issues, workflow)
367
+ # GateContract validation is opt-in. Older/custom workflows intentionally
368
+ # use a smaller checkpoint shape and must keep the legacy control-plane
369
+ # behavior until they declare the contract version.
370
+ return unless workflow.dig("governance", "gate_contract_version").to_i >= 3
371
+
372
+ WorkflowGateContract.validate(workflow).each do |entry|
373
+ issues << issue("error", entry["code"], entry["message"], entry.reject { |key, _| %w[code message].include?(key) })
374
+ end
375
+ end
376
+
377
+ def validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
268
378
  completed_ids.each do |step_id|
269
379
  step = step_by_id[step_id]
270
380
  next unless step
271
381
 
272
382
  validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, waiting_checkpoint_step?(state, step_id))
273
- validate_meta_output(issues, root, state, step)
383
+ validate_meta_output(issues, root, state, step, workflow)
274
384
  end
275
385
  end
276
386
 
@@ -390,7 +500,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
390
500
  end
391
501
  end
392
502
 
393
- def validate_meta_output(issues, root, state, step)
503
+ def validate_meta_output(issues, root, state, step, workflow = nil)
394
504
  meta = step["meta_output"]
395
505
  return unless meta
396
506
 
@@ -411,6 +521,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
411
521
 
412
522
  validate_requirement_triage_gate(issues, step, data)
413
523
  validate_requirement_wayfinder_gate(issues, step, data)
524
+ validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
414
525
  validate_domain_modeling_gate(issues, step, data)
415
526
  validate_requirement_feasibility_review_gate(issues, step, data)
416
527
  validate_requirement_baseline_gate(issues, step, data)
@@ -427,6 +538,21 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
427
538
  issues << issue("error", "invalid_meta_json", e.message, step: step["id"], path: path)
428
539
  end
429
540
 
541
+ def validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
542
+ return unless step["id"] == "requirement-wayfinder"
543
+ return unless workflow.nil? || workflow.dig("governance", "requirement_coverage_gate").to_s == "enforced"
544
+
545
+ result = RequirementCoverage.validate(root: root, feature: state.fetch("current_feature"))
546
+ return if result["verdict"] == "allow"
547
+
548
+ Array(result["errors"]).each do |entry|
549
+ issues << issue("error", entry["code"], entry["message"], step: step["id"], coverage: entry)
550
+ end
551
+ if result["verdict"] == "await_user"
552
+ issues << issue("error", "wayfinder_coverage_requires_decision", "Wayfinder coverage requires user review before completion", step: step["id"], counts: result["counts"])
553
+ end
554
+ end
555
+
430
556
  def validate_requirement_triage_gate(issues, step, data)
431
557
  return unless step["id"] == "requirement-triage"
432
558