@guanlin4924/devflow-pi 0.1.40 → 0.1.48

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 (50) hide show
  1. package/bin/apply-opencode-go-session-hotfix.mjs +61 -0
  2. package/package.json +2 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
  4. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
  5. package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
  6. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
  7. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
  8. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
  9. package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
  11. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +143 -28
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
  26. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
  27. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
  28. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
  29. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -1
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +13 -4
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  43. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  44. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
  45. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  46. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  47. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  48. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  49. package/src/constants.ts +1 -1
  50. package/src/runtime.ts +53 -22
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Deterministically resolves business-model provenance against requirement
5
+ # governance, source assertions, code-index snapshots, domain documents, and
6
+ # Core-owned user decisions. AI-provided source labels are never trusted.
7
+ require "digest"
8
+ require "json"
9
+ require "open3"
10
+ require "optparse"
11
+ require "pathname"
12
+ require "yaml"
13
+ require_relative "requirement_graph"
14
+
15
+ module BusinessSourceResolver
16
+ SOURCE_TYPES = %w[requirement code domain_rule user_decision].freeze
17
+ REGISTRY_SCHEMA = File.expand_path("../contracts/business-source-registry.schema.yaml", __dir__)
18
+ REFERENCE_ID = /\A[A-Za-z][A-Za-z0-9_-]*\z/.freeze
19
+
20
+ module_function
21
+
22
+ def add(errors, code, message, source_id: nil, path: nil)
23
+ entry = { "code" => code, "message" => message }
24
+ entry["source_id"] = source_id if source_id
25
+ entry["path"] = path if path
26
+ errors << entry
27
+ end
28
+
29
+ def load_yaml(path)
30
+ value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
31
+ raise ArgumentError, "YAML root must be an object" unless value.is_a?(Hash)
32
+
33
+ value
34
+ rescue Psych::Exception => e
35
+ raise ArgumentError, "invalid YAML: #{e.message}"
36
+ end
37
+
38
+ def deep_sort(value)
39
+ case value
40
+ when Hash then value.keys.sort.to_h { |key| [key, deep_sort(value[key])] }
41
+ when Array then value.map { |item| deep_sort(item) }
42
+ else value
43
+ end
44
+ end
45
+
46
+ def fingerprint(value)
47
+ "sha256:#{Digest::SHA256.hexdigest(JSON.generate(deep_sort(value)))}"
48
+ end
49
+
50
+ def file_fingerprint(path)
51
+ "sha256:#{Digest::SHA256.file(path).hexdigest}"
52
+ end
53
+
54
+ def valid_feature?(feature)
55
+ RequirementGraph.valid_feature?(feature)
56
+ end
57
+
58
+ def safe_path(root, raw, kind, must_exist: true)
59
+ root_real = File.realpath(root)
60
+ value = raw.to_s
61
+ raise ArgumentError, "#{kind} path is required" if value.empty?
62
+ raise ArgumentError, "#{kind} path must be project-relative" if Pathname.new(value).absolute?
63
+ raise ArgumentError, "#{kind} path contains control characters" if value.match?(/[\x00-\x1F\x7F]/)
64
+
65
+ normalized = Pathname.new(value).cleanpath.to_s.tr("\\", "/")
66
+ raise ArgumentError, "#{kind} path traverses parent directories" if normalized == ".." || normalized.start_with?("../")
67
+ absolute = File.expand_path(normalized, root_real)
68
+ raise ArgumentError, "#{kind} path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
69
+ raise ArgumentError, "#{kind} path is missing" if must_exist && !File.file?(absolute)
70
+
71
+ cursor = root_real
72
+ normalized.split("/").each do |segment|
73
+ cursor = File.join(cursor, segment)
74
+ next unless File.symlink?(cursor)
75
+
76
+ resolved = File.realpath(cursor)
77
+ raise ArgumentError, "#{kind} path escapes through symlink" unless resolved == root_real || resolved.start_with?("#{root_real}#{File::SEPARATOR}")
78
+ end
79
+ [absolute, normalized]
80
+ end
81
+
82
+ def command(root, *args)
83
+ stdout, _stderr, status = Open3.capture3(*args, chdir: root)
84
+ status.success? ? stdout.strip : ""
85
+ rescue Errno::ENOENT
86
+ ""
87
+ end
88
+
89
+ def command_success?(root, *args)
90
+ _stdout, _stderr, status = Open3.capture3(*args, chdir: root)
91
+ status.success?
92
+ rescue Errno::ENOENT
93
+ false
94
+ end
95
+
96
+ def repository_revision(root)
97
+ command(root, "git", "rev-parse", "HEAD")
98
+ end
99
+
100
+ def worktree_signature(root)
101
+ output = command(root, "git", "status", "--porcelain=v1", "--untracked-files=all")
102
+ lines = output.lines.map(&:rstrip).reject do |line|
103
+ path = line.length > 3 ? line[3..].to_s.strip : ""
104
+ path.start_with?(".planning/", ".context/")
105
+ end
106
+ "sha256:#{Digest::SHA256.hexdigest(lines.join("\n"))}"
107
+ end
108
+
109
+ def validate_shape(registry, schema, feature, errors)
110
+ top = schema.fetch("top_level")
111
+ allowed = top.fetch("properties").keys
112
+ registry.keys.each { |key| add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "unknown registry field #{key}", path: key) unless allowed.include?(key) }
113
+ top.fetch("required").each { |key| add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "required registry field #{key} is missing", path: key) unless registry.key?(key) }
114
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "schema_version must be 1", path: "schema_version") unless registry["schema_version"] == 1
115
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "feature does not match", path: "feature") unless registry["feature"] == feature
116
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "registry_revision must be a non-negative integer", path: "registry_revision") unless registry["registry_revision"].is_a?(Integer) && registry["registry_revision"] >= 0
117
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "sources must be an array", path: "sources") unless registry["sources"].is_a?(Array)
118
+ end
119
+
120
+ def validate_source_shape(source, schema, errors, seen)
121
+ unless source.is_a?(Hash)
122
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "source must be an object")
123
+ return false
124
+ end
125
+ id = source["id"].to_s
126
+ pattern = Regexp.new(schema.fetch("id_pattern"))
127
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "source ID is invalid", source_id: id) unless id.match?(pattern)
128
+ if seen.include?(id)
129
+ add(errors, "BUSINESS_SOURCE_DUPLICATE_ID", "duplicate source ID", source_id: id)
130
+ else
131
+ seen << id
132
+ end
133
+ type = source["type"].to_s
134
+ variant = schema.fetch("source_variants", {})[type]
135
+ unless SOURCE_TYPES.include?(type) && variant.is_a?(Hash)
136
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "unsupported source type #{type}", source_id: id)
137
+ return false
138
+ end
139
+ allowed = variant.fetch("properties").keys
140
+ source.keys.each do |key|
141
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "field #{key} is not valid for #{type}", source_id: id, path: key) unless allowed.include?(key)
142
+ end
143
+ variant.fetch("required").each do |key|
144
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "required field #{key} is missing", source_id: id, path: key) unless source.key?(key)
145
+ end
146
+ variant.fetch("properties").each do |key, property|
147
+ validate_contract_value(source[key], property, errors, id, key) if source.key?(key)
148
+ end
149
+ source["locator"].is_a?(Hash) || add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "locator must be an object", source_id: id, path: "locator")
150
+ locator = source["locator"]
151
+ locator_variant = schema.fetch("locator_variants", {})[type]
152
+ if locator.is_a?(Hash) && locator_variant.is_a?(Hash)
153
+ locator_allowed = locator_variant.fetch("properties").keys
154
+ locator.keys.each do |key|
155
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "locator field #{key} is not valid for #{type}", source_id: id, path: "locator.#{key}") unless locator_allowed.include?(key)
156
+ end
157
+ locator_variant.fetch("required", []).each do |key|
158
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "required locator field #{key} is missing", source_id: id, path: "locator.#{key}") unless locator.key?(key)
159
+ end
160
+ locator_variant.fetch("properties").each do |key, property|
161
+ validate_contract_value(locator[key], property, errors, id, "locator.#{key}") if locator.key?(key)
162
+ end
163
+ alternatives = locator_variant.fetch("at_least_one", [])
164
+ if alternatives.any? && alternatives.none? { |key| locator.key?(key) }
165
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "locator requires at least one of #{alternatives.join(', ')}", source_id: id, path: "locator")
166
+ end
167
+ end
168
+ true
169
+ end
170
+
171
+ def validate_contract_value(value, property, errors, source_id, path)
172
+ return unless property.is_a?(Hash)
173
+
174
+ if property.key?("const") && value != property["const"]
175
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "value must equal #{property['const']}", source_id: source_id, path: path)
176
+ return
177
+ end
178
+ valid = case property["type"]
179
+ when "string" then value.is_a?(String) && (!property["non_empty"] || !value.strip.empty?)
180
+ when "integer" then value.is_a?(Integer) && (!property.key?("minimum") || value >= property["minimum"])
181
+ when "id" then value.is_a?(String) && value.match?(REFERENCE_ID)
182
+ when "requirement_locator", "code_locator", "domain_locator", "decision_locator" then value.is_a?(Hash)
183
+ else true
184
+ end
185
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "value does not satisfy #{property['type'] || 'contract'}", source_id: source_id, path: path) unless valid
186
+ end
187
+
188
+ def verify_file_fingerprint(root, source, field, errors)
189
+ path, relative = safe_path(root, source[field], field)
190
+ actual = file_fingerprint(path)
191
+ if source["source_fingerprint"].to_s != actual
192
+ add(errors, "BUSINESS_SOURCE_FINGERPRINT_MISMATCH", "registered fingerprint does not match current file", source_id: source["id"], path: relative)
193
+ end
194
+ [path, relative, actual]
195
+ rescue ArgumentError => e
196
+ add(errors, "BUSINESS_SOURCE_NOT_FOUND", e.message, source_id: source["id"], path: source[field].to_s)
197
+ nil
198
+ end
199
+
200
+ def verify_requirement(root, feature, source, errors)
201
+ file_info = verify_file_fingerprint(root, source, "document", errors)
202
+ locator = source["locator"]
203
+ requirement_ref = locator.is_a?(Hash) ? locator["requirement_ref"].to_s : ""
204
+ assertion_id = locator.is_a?(Hash) ? locator["assertion_id"].to_s : ""
205
+ begin
206
+ graph, = RequirementGraph.load_graph(root, feature)
207
+ requirement = Array(graph["nodes"]).find { |node| node.is_a?(Hash) && node["id"] == requirement_ref && node["kind"] == "requirement" }
208
+ unless requirement && %w[confirmed approved].include?(requirement["status"].to_s)
209
+ add(errors, "BUSINESS_REQUIREMENT_REF_INVALID", "requirement_ref must resolve to a confirmed/approved requirement", source_id: source["id"], path: requirement_ref)
210
+ end
211
+ rescue ArgumentError => e
212
+ add(errors, "BUSINESS_REQUIREMENT_REF_INVALID", e.message, source_id: source["id"], path: requirement_ref)
213
+ end
214
+ units_path = File.join(RequirementGraph.feature_dir(root, feature), "source_units.yaml")
215
+ unless File.file?(units_path)
216
+ add(errors, "BUSINESS_ASSERTION_NOT_FOUND", "source_units.yaml is missing", source_id: source["id"], path: units_path)
217
+ return
218
+ end
219
+ units = load_yaml(units_path)
220
+ assertions = Array(units["units"]).flat_map { |unit| Array(unit["required_assertions"]) }
221
+ add(errors, "BUSINESS_ASSERTION_NOT_FOUND", "assertion locator is not present in source_units", source_id: source["id"], path: assertion_id) unless assertions.any? { |item| item.is_a?(Hash) && item["assertion_id"].to_s == assertion_id }
222
+ if file_info && units["source_document_fingerprint"].to_s != file_info[2]
223
+ add(errors, "BUSINESS_SOURCE_FINGERPRINT_MISMATCH", "source_units fingerprint does not match the requirement document", source_id: source["id"], path: units_path)
224
+ end
225
+ rescue ArgumentError => e
226
+ add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", e.message, source_id: source["id"])
227
+ end
228
+
229
+ def verify_code(root, source, errors)
230
+ id = source["id"]
231
+ path_info = safe_path(root, source["path"], "code")
232
+ path, relative = path_info
233
+ actual_hash = file_fingerprint(path)
234
+ add(errors, "BUSINESS_CODE_CONTENT_MISMATCH", "code content_hash is stale", source_id: id, path: relative) unless source["content_hash"].to_s == actual_hash
235
+ current_revision = repository_revision(root)
236
+ add(errors, "BUSINESS_CODE_REVISION_MISMATCH", "repository revision is stale", source_id: id) unless !current_revision.empty? && source["repository_revision"].to_s == current_revision
237
+ current_signature = worktree_signature(root)
238
+ add(errors, "BUSINESS_WORKTREE_SIGNATURE_MISMATCH", "worktree signature is stale", source_id: id) unless source["worktree_signature"].to_s == current_signature
239
+
240
+ snapshot_path = File.join(root, ".planning", "code-index", "SNAPSHOT.json")
241
+ unless File.file?(snapshot_path)
242
+ add(errors, "BUSINESS_CODE_INDEX_STALE", "code index snapshot is missing", source_id: id, path: snapshot_path)
243
+ return
244
+ end
245
+ snapshot = JSON.parse(File.read(snapshot_path))
246
+ if snapshot["generation"].to_s != source["index_generation"].to_s || snapshot.dig("workspace", "repositoryRevision").to_s != current_revision
247
+ add(errors, "BUSINESS_CODE_INDEX_STALE", "code index generation or repository revision is stale", source_id: id, path: snapshot_path)
248
+ end
249
+ symbol = source.dig("locator", "symbol").to_s
250
+ line_start = source.dig("locator", "line_start")
251
+ if symbol.empty? && !line_start.is_a?(Integer)
252
+ add(errors, "BUSINESS_CODE_LOCATOR_INVALID", "code locator requires symbol or line_start", source_id: id)
253
+ return
254
+ end
255
+ if line_start.is_a?(Integer)
256
+ line_count = File.foreach(path).count
257
+ add(errors, "BUSINESS_CODE_LOCATOR_INVALID", "line_start is outside the current file", source_id: id, path: relative) unless line_start.positive? && line_start <= line_count
258
+ end
259
+ return if symbol.empty?
260
+
261
+ symbols_path = File.join(root, ".planning", "code-index", "symbols.jsonl")
262
+ unless File.file?(symbols_path)
263
+ add(errors, "BUSINESS_CODE_SYMBOL_NOT_FOUND", "symbols index is missing", source_id: id, path: symbols_path)
264
+ return
265
+ end
266
+ symbols = File.readlines(symbols_path, chomp: true).map do |line|
267
+ JSON.parse(line)
268
+ rescue JSON::ParserError
269
+ nil
270
+ end.compact
271
+ match = symbols.find do |entry|
272
+ entry["path"].to_s == relative && [entry["name"].to_s, entry["qualified_name"].to_s].include?(symbol) &&
273
+ (!line_start.is_a?(Integer) || (entry["line_start"].to_i <= line_start && entry["line_end"].to_i >= line_start))
274
+ end
275
+ add(errors, "BUSINESS_CODE_SYMBOL_NOT_FOUND", "symbol/line is not present in the current index", source_id: id, path: relative) unless match
276
+
277
+ return unless source["commit"]
278
+
279
+ unless command_success?(root, "git", "cat-file", "-e", "#{source['commit']}:#{relative}")
280
+ add(errors, "BUSINESS_CODE_COMMIT_INVALID", "commit does not contain the registered code path", source_id: id, path: relative)
281
+ end
282
+ rescue ArgumentError => e
283
+ add(errors, "BUSINESS_CODE_PATH_INVALID", e.message, source_id: source["id"], path: source["path"].to_s)
284
+ rescue JSON::ParserError => e
285
+ add(errors, "BUSINESS_CODE_INDEX_STALE", "invalid code index snapshot: #{e.message}", source_id: source["id"])
286
+ end
287
+
288
+ def verify_domain_rule(root, source, errors)
289
+ file_info = verify_file_fingerprint(root, source, "document", errors)
290
+ return unless file_info
291
+
292
+ clause = source.dig("locator", "clause_id").to_s
293
+ if clause.empty? || !File.readlines(file_info[0], chomp: true).any? { |line| line.include?(clause) }
294
+ add(errors, "BUSINESS_DOMAIN_RULE_INVALID", "domain clause locator is not present in the document", source_id: source["id"], path: clause)
295
+ end
296
+ end
297
+
298
+ def verify_user_decision(root, feature, source, errors)
299
+ path = File.join(RequirementGraph.feature_dir(root, feature), "business-decisions.json")
300
+ unless File.file?(path)
301
+ add(errors, "BUSINESS_DECISION_INVALID", "Core decision registry is missing", source_id: source["id"], path: path)
302
+ return
303
+ end
304
+ registry = JSON.parse(File.read(path))
305
+ stored_fingerprint = registry["fingerprint"].to_s
306
+ payload = registry.reject { |key, _| key == "fingerprint" }
307
+ actual_fingerprint = fingerprint(payload)
308
+ decision_id = source.dig("locator", "decision_id").to_s
309
+ valid = registry["feature"] == feature && registry["revision"] == source["decision_revision"] &&
310
+ stored_fingerprint == actual_fingerprint && source["decision_fingerprint"].to_s == actual_fingerprint &&
311
+ Array(registry["decisions"]).any? { |entry| entry.is_a?(Hash) && entry["id"].to_s == decision_id }
312
+ add(errors, "BUSINESS_DECISION_INVALID", "decision revision/fingerprint/locator is not current", source_id: source["id"], path: decision_id) unless valid
313
+ rescue JSON::ParserError => e
314
+ add(errors, "BUSINESS_DECISION_INVALID", "invalid decision registry: #{e.message}", source_id: source["id"], path: path)
315
+ end
316
+
317
+ def validate(root:, feature:, registry_path: nil)
318
+ raise ArgumentError, "invalid feature" unless valid_feature?(feature)
319
+ root = File.realpath(root)
320
+ schema = load_yaml(REGISTRY_SCHEMA)
321
+ raw_registry = registry_path || File.join(".planning", "business-model", feature, "source-registry.yaml")
322
+ registry_absolute, registry_relative = safe_path(root, raw_registry, "source registry")
323
+ registry = load_yaml(registry_absolute)
324
+ errors = []
325
+ validate_shape(registry, schema, feature, errors)
326
+ sources = registry["sources"].is_a?(Array) ? registry["sources"] : []
327
+ seen = []
328
+ sources.each do |source|
329
+ next unless validate_source_shape(source, schema, errors, seen)
330
+
331
+ case source["type"]
332
+ when "requirement" then verify_requirement(root, feature, source, errors)
333
+ when "code" then verify_code(root, source, errors)
334
+ when "domain_rule" then verify_domain_rule(root, source, errors)
335
+ when "user_decision" then verify_user_decision(root, feature, source, errors)
336
+ end
337
+ end
338
+ registry_fingerprint = fingerprint(registry)
339
+ {
340
+ "ok" => errors.empty?,
341
+ "verdict" => errors.empty? ? "allow" : "block_unsafe",
342
+ "errors" => errors,
343
+ "warnings" => [],
344
+ "source_registry_path" => registry_relative,
345
+ "source_registry_fingerprint" => registry_fingerprint,
346
+ "resolved_source_ids" => errors.empty? ? sources.map { |source| source["id"] } : []
347
+ }
348
+ rescue ArgumentError, Errno::ENOENT => e
349
+ { "ok" => false, "verdict" => "block_unsafe", "errors" => [{ "code" => "BUSINESS_SOURCE_SCHEMA_INVALID", "message" => e.message }], "warnings" => [] }
350
+ end
351
+ end
352
+
353
+ if $PROGRAM_NAME == __FILE__
354
+ options = {}
355
+ OptionParser.new do |opts|
356
+ opts.on("--root PATH") { |value| options[:root] = value }
357
+ opts.on("--feature NAME") { |value| options[:feature] = value }
358
+ opts.on("--registry PATH") { |value| options[:registry_path] = value }
359
+ opts.on("--json") { options[:json] = true }
360
+ end.parse!
361
+ abort("--root and --feature are required") unless options[:root] && options[:feature]
362
+ payload = BusinessSourceResolver.validate(root: options[:root], feature: options[:feature], registry_path: options[:registry_path])
363
+ puts JSON.pretty_generate(payload)
364
+ exit(payload["ok"] ? 0 : 1)
365
+ end
@@ -7,7 +7,8 @@ module CheckerRegistry
7
7
  api-test-schema-check contract-coverage-check api-test-matrix-check
8
8
  test-coverage-check test-obligation-check acceptance-test-results-check
9
9
  change-impact-check tdd-evidence-check test-task-binding-check
10
- cross-track-consistency-check
10
+ cross-track-consistency-check business-closure-check business-claim-reference-check
11
+ business-cross-model-check business-invalidation-check
11
12
  ].freeze
12
13
  EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
13
14
 
@@ -11,10 +11,10 @@ require_relative "control_io"
11
11
  require_relative "requirement_graph"
12
12
 
13
13
  POLICY = {
14
- "requirement-baseline" => { "checkers" => %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
14
+ "requirement-baseline" => { "checkers" => %w[requirement-graph-check business-closure-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
15
15
  "api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
16
16
  "architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk metric read_model] },
17
- "integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
17
+ "integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-check business-claim-reference-check business-cross-model-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
18
18
  "acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
19
19
  "change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
20
20
  "execute" => { "checkers" => %w[tdd-evidence-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
@@ -69,7 +69,7 @@ begin
69
69
  "state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_path" => workflow_relative,
70
70
  "workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw), "manifest_id" => "devflow-core-execution-policy-v2",
71
71
  "allowed_paths" => paths, "allowed_agents" => policy["agents"], "checker_ids" => policy["checkers"],
72
- "allowed_tools" => %w[read grep find glob lsp search write edit apply_patch bash shell exec_command task devflow_api_test_matrix devflow_tdd_evidence devflow_run_acceptance_tests],
72
+ "allowed_tools" => %w[read grep find glob lsp search write edit apply_patch bash shell exec_command task devflow_api_test_matrix devflow_tdd_evidence devflow_run_acceptance_tests devflow_mainflow],
73
73
  "tool_budget" => policy["budget"], "acceptance" => Array(graph["nodes"]).select { |node| node["kind"] == "acceptance" }.map { |node| node["id"] },
74
74
  "allowed_graph_delta" => Array(policy["allowed_graph_delta"]),
75
75
  "on_unknown" => "block_and_escalate"
@@ -24,6 +24,9 @@ module GateContract
24
24
  %w[allowed_tracks allowed_predecessors].each do |field|
25
25
  raise ArgumentError, "#{field} must be an array" unless topology[field].is_a?(Array)
26
26
  end
27
+ if topology.key?("allowed_intervening_predecessors") && !topology["allowed_intervening_predecessors"].is_a?(Array)
28
+ raise ArgumentError, "allowed_intervening_predecessors must be an array"
29
+ end
27
30
  raise ArgumentError, "allowed_reentry must be an array" unless topology["allowed_reentry"].is_a?(Array)
28
31
  validate_reentries!(topology["allowed_reentry"])
29
32
  checkpoint = topology["checkpoint"]
@@ -6,12 +6,17 @@ require_relative "gate_result"
6
6
  require_relative "gate_contract"
7
7
  require_relative "checker_registry"
8
8
  require_relative "parallel_run"
9
+ require_relative "business_model_gate"
9
10
 
10
11
  module GateRuntime
11
- WORKFLOW_STATUSES = %w[in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
12
+ WORKFLOW_STATUSES = %w[not_started in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
12
13
 
13
14
  module_function
14
15
 
16
+ def evaluate_business_model(violations:, transition_kind:, gate_id: "business-model-closure", step_id: "business-model")
17
+ BusinessModelGate.evaluate(violations: violations, transition_kind: transition_kind, gate_id: gate_id, step_id: step_id)
18
+ end
19
+
15
20
  def validate_contract!(contract)
16
21
  GateContract.validate!(contract)
17
22
  CheckerRegistry.validate!(Array(contract.dig("quality", "checker_ids")))
@@ -48,12 +53,13 @@ module GateRuntime
48
53
  return GateResult.block(**base, code: "TRACK_SCOPE_VIOLATION", message: "current track is not allowed by GateContract", recovery: { "kind" => "registered_reentry" })
49
54
  end
50
55
  allowed_predecessors = Array(contract.dig("topology", "allowed_predecessors"))
56
+ allowed_intervening = Array(contract.dig("topology", "allowed_intervening_predecessors"))
51
57
  previous = workflow_state["last_completed_step"] || workflow_state["previous_step"]
52
- if previous && !allowed_predecessors.empty? && !allowed_predecessors.include?(previous)
58
+ if previous && !allowed_predecessors.empty? && !allowed_predecessors.include?(previous) && !allowed_intervening.include?(previous)
53
59
  return GateResult.block(**base, code: "STEP_PREDECESSOR_INVALID", message: "previous step is not allowed by GateContract")
54
60
  end
55
61
  if root
56
- missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state)
62
+ missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state, allow_missing_collections: true)
57
63
  return GateResult.block(**base, code: "REQUIRED_INPUT_MISSING", message: "required gate input is missing", details: { "missing" => missing }) unless missing.empty?
58
64
  if check_outputs
59
65
  missing_outputs = required_paths(contract.dig("outputs", "required"), root, workflow_state)
@@ -142,13 +148,16 @@ module GateRuntime
142
148
  )
143
149
  end
144
150
 
145
- def required_paths(entries, root, state)
151
+ def required_paths(entries, root, state, allow_missing_collections: false)
146
152
  Array(entries).each_with_object([]) do |entry, missing|
147
153
  raw = entry.is_a?(Hash) ? entry["path"] : entry
148
154
  next if raw.to_s.empty?
149
155
  relative = raw.to_s.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
150
156
  absolute = File.expand_path(relative, root)
151
157
  root_real = File.realpath(root)
158
+ root_expanded = File.expand_path(root)
159
+ inside_root = absolute.start_with?("#{root_expanded}#{File::SEPARATOR}")
160
+ next if allow_missing_collections && raw.to_s.end_with?(File::SEPARATOR) && inside_root && !File.exist?(absolute)
152
161
  actual = File.exist?(absolute) ? File.realpath(absolute) : absolute
153
162
  next if actual.start_with?("#{root_real}#{File::SEPARATOR}") && File.exist?(actual)
154
163
  missing << relative