@guanlin4924/devflow-pi 0.1.39 → 0.1.47

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 (53) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +728 -39
  3. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
  4. package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
  5. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
  6. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
  7. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
  8. package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +334 -30
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
  26. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
  27. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
  28. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
  29. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -0
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
  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 +84 -6
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
  43. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  44. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  45. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  46. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
  47. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
  48. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  49. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  50. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  51. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  52. package/src/constants.ts +1 -1
  53. package/src/runtime.ts +61 -24
@@ -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,6 +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 business-closure-check business-claim-reference-check
11
+ business-cross-model-check business-invalidation-check
10
12
  ].freeze
11
13
  EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
12
14
 
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "optparse"
6
+ require "yaml"
7
+
8
+ options = { feature: nil, json: false }
9
+ OptionParser.new do |opts|
10
+ opts.on("--feature FEATURE") { |value| options[:feature] = value }
11
+ opts.on("--json") { options[:json] = true }
12
+ end.parse!
13
+
14
+ raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
15
+ feature = options[:feature].to_s
16
+ raise ArgumentError, "feature contains path separators" if feature.include?("/") || feature.include?("\\")
17
+
18
+ root = Dir.pwd
19
+ base = File.join(root, ".planning", "features", feature)
20
+ files = {
21
+ "api_contract" => File.join(base, "api-contract.md"),
22
+ "api_call_guide" => File.join(base, "api-call-guide.md"),
23
+ "frontend_spec" => File.join(base, "前端技术方案.md"),
24
+ "backend_spec" => File.join(base, "后端技术方案.md"),
25
+ "api_supplement" => File.join(base, "api-supplement-requests.md"),
26
+ }
27
+ errors = []
28
+ missing = files.select { |_, path| !File.file?(path) }.keys
29
+ errors << { "code" => "CROSS_TRACK_ARTIFACT_MISSING", "files" => missing } unless missing.empty?
30
+
31
+ read = lambda do |key|
32
+ path = files.fetch(key)
33
+ File.file?(path) ? File.read(path) : ""
34
+ end
35
+ contract = read.call("api_contract")
36
+ guide = read.call("api_call_guide")
37
+ frontend = read.call("frontend_spec")
38
+
39
+ # Endpoint references are deliberately extracted from the authoritative API
40
+ # contract and checked for textual closure in both consumers. This is a
41
+ # deterministic reference-closure check; an AI integration summary is not used.
42
+ endpoint_pattern = %r{\b(?:GET|POST|PUT|PATCH|DELETE)\s+(/[A-Za-z0-9_./:-]+(?:\{[A-Za-z0-9_]+\})*)}
43
+ endpoints = contract.scan(endpoint_pattern).flatten.uniq
44
+ unreferenced = endpoints.reject { |endpoint| guide.include?(endpoint) && frontend.include?(endpoint) }
45
+ unless unreferenced.empty?
46
+ errors << { "code" => "CROSS_TRACK_ENDPOINT_REFERENCE_MISSING", "endpoints" => unreferenced }
47
+ end
48
+
49
+ contract_set = endpoints.to_h { |endpoint| [endpoint, true] }
50
+ {
51
+ "api_call_guide" => guide.scan(endpoint_pattern).flatten.uniq,
52
+ "frontend_spec" => frontend.scan(endpoint_pattern).flatten.uniq,
53
+ }.each do |consumer, references|
54
+ ghosts = references.reject { |endpoint| contract_set[endpoint] }
55
+ errors << { "code" => "CROSS_TRACK_ENDPOINT_NOT_IN_CONTRACT", "consumer" => consumer, "endpoints" => ghosts } unless ghosts.empty?
56
+ end
57
+
58
+ supplement = read.call("api_supplement")
59
+ supplement_endpoints = supplement.scan(endpoint_pattern).flatten.uniq
60
+ supplement_ghosts = supplement_endpoints.reject { |endpoint| contract_set[endpoint] }
61
+ errors << { "code" => "CROSS_TRACK_SUPPLEMENT_ENDPOINT_NOT_IN_CONTRACT", "endpoints" => supplement_ghosts } unless supplement_ghosts.empty?
62
+ supplement_unconsumed = supplement_endpoints.reject { |endpoint| guide.include?(endpoint) }
63
+ errors << { "code" => "CROSS_TRACK_SUPPLEMENT_GUIDE_MISMATCH", "endpoints" => supplement_unconsumed } unless supplement_unconsumed.empty?
64
+
65
+ meta_path = File.join(base, "api-call-guide.meta.json")
66
+ if File.file?(meta_path)
67
+ begin
68
+ meta = JSON.parse(File.read(meta_path))
69
+ errors << { "code" => "CROSS_TRACK_API_GUIDE_NOT_CONSUMABLE" } unless meta["frontend_consumable"] == true && meta["mock_ready"] == true
70
+ pending = meta["pending_items"]
71
+ errors << { "code" => "CROSS_TRACK_API_GUIDE_PENDING_ITEMS", "pending_items" => pending } unless pending.is_a?(Array) && pending.empty?
72
+ rescue JSON::ParserError => error
73
+ errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
74
+ end
75
+ end
76
+
77
+ supplement_meta = File.join(base, "api-supplement-requests.meta.json")
78
+ if File.file?(supplement_meta)
79
+ begin
80
+ meta = JSON.parse(File.read(supplement_meta))
81
+ unresolved = meta["unresolved_supplement_count"]
82
+ errors << { "code" => "CROSS_TRACK_SUPPLEMENT_UNRESOLVED", "count" => unresolved } unless unresolved == 0
83
+ rescue JSON::ParserError => error
84
+ errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
85
+ end
86
+ end
87
+
88
+ result = {
89
+ "ok" => errors.empty?,
90
+ "checker_id" => "cross-track-consistency-check",
91
+ "errors" => errors,
92
+ "evidence" => { "endpoint_count" => endpoints.length, "checked_files" => files.values.map { |path| path.sub("#{root}/", "") } },
93
+ }
94
+ puts(options[:json] ? JSON.generate(result) : result.to_yaml)
@@ -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], "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"]