@guanlin4924/devflow-pi 0.1.40 → 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 (49) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
  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 +143 -28
  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 -1
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +13 -4
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  43. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
  44. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  45. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  46. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  47. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  48. package/src/constants.ts +1 -1
  49. package/src/runtime.ts +53 -22
@@ -0,0 +1,570 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Core-owned deterministic aggregate/canonical builder. It never makes a
5
+ # semantic choice: differing definitions remain unresolved conflicts until a
6
+ # later runtime-confirmed adjudication applies a complete candidate delta.
7
+ require "digest"
8
+ require "json"
9
+ require "open3"
10
+ require "optparse"
11
+ require "pathname"
12
+ require "set"
13
+ require "tempfile"
14
+ require "time"
15
+ require "yaml"
16
+ require_relative "control_io"
17
+ require_relative "requirement_graph"
18
+
19
+ module BusinessModelCommit
20
+ COLLECTIONS = %w[actors entities components boundaries resources rules actions assumptions].freeze
21
+ REFERENCE_LISTS = %w[requirement_refs decision_refs resolved_assumption_refs].freeze
22
+ VALIDATOR = File.expand_path("business_model_validate.py", __dir__)
23
+
24
+ module_function
25
+
26
+ def deep_sort(value)
27
+ case value
28
+ when Hash then value.keys.sort.to_h { |key| [key, deep_sort(value[key])] }
29
+ when Array then value.map { |item| deep_sort(item) }
30
+ else value
31
+ end
32
+ end
33
+
34
+ def fingerprint(value)
35
+ "sha256:#{Digest::SHA256.hexdigest(JSON.generate(deep_sort(value)))}"
36
+ end
37
+
38
+ def valid_feature?(feature)
39
+ RequirementGraph.valid_feature?(feature)
40
+ end
41
+
42
+ def feature_dir(root, feature)
43
+ raise ArgumentError, "invalid feature" unless valid_feature?(feature)
44
+
45
+ root_real = File.realpath(root)
46
+ relative = File.join(".planning", "business-model", feature)
47
+ absolute = File.expand_path(relative, root_real)
48
+ raise ArgumentError, "business model path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
49
+
50
+ cursor = root_real
51
+ relative.split(File::SEPARATOR).each do |segment|
52
+ cursor = File.join(cursor, segment)
53
+ next unless File.symlink?(cursor)
54
+
55
+ resolved = File.realpath(cursor)
56
+ raise ArgumentError, "business model path escapes through symlink" unless resolved == root_real || resolved.start_with?("#{root_real}#{File::SEPARATOR}")
57
+ end
58
+
59
+ absolute
60
+ end
61
+
62
+ def proposal_path(root, feature, raw)
63
+ root_real = File.realpath(root)
64
+ value = raw.to_s
65
+ raise ArgumentError, "proposal path is required" if value.empty?
66
+ raise ArgumentError, "proposal path must be project-relative" if Pathname.new(value).absolute?
67
+ raise ArgumentError, "proposal path contains control characters" if value.match?(/[\x00-\x1F\x7F]/)
68
+
69
+ normalized = Pathname.new(value).cleanpath.to_s.tr("\\", "/")
70
+ prefix = File.join(".planning", "business-model", feature, "proposals").tr("\\", "/") + "/"
71
+ raise ArgumentError, "proposal path is outside the feature proposal directory" unless normalized.start_with?(prefix)
72
+ absolute = File.expand_path(normalized, root_real)
73
+ raise ArgumentError, "proposal path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
74
+ raise ArgumentError, "proposal file is missing" unless File.file?(absolute)
75
+
76
+ cursor = root_real
77
+ normalized.split("/").each do |segment|
78
+ cursor = File.join(cursor, segment)
79
+ next unless File.symlink?(cursor)
80
+
81
+ resolved = File.realpath(cursor)
82
+ raise ArgumentError, "proposal path escapes through symlink" unless resolved == root_real || resolved.start_with?("#{root_real}#{File::SEPARATOR}")
83
+ end
84
+ [absolute, normalized]
85
+ end
86
+
87
+ def proposal_paths(root, feature, requested)
88
+ if requested.empty?
89
+ directory = File.join(feature_dir(root, feature), "proposals")
90
+ requested = Dir.glob(File.join(directory, "*.{yaml,yml,json}")).sort.map do |path|
91
+ Pathname.new(path).relative_path_from(Pathname.new(File.realpath(root))).to_s
92
+ end
93
+ end
94
+ raise ArgumentError, "at least one proposal is required" if requested.empty?
95
+
96
+ requested.map { |raw| proposal_path(root, feature, raw) }.sort_by { |_absolute, relative| relative }
97
+ end
98
+
99
+ def validate_proposal(absolute, relative, feature)
100
+ stdout, stderr, status = Open3.capture3("python3", VALIDATOR, "--proposal", absolute)
101
+ payload = JSON.parse(stdout)
102
+ unless status.success? && payload["valid"] == true
103
+ details = Array(payload["errors"]).map { |error| error["code"] }.uniq.join(", ")
104
+ raise ArgumentError, "proposal #{relative} is invalid: #{details.empty? ? stderr.strip : details}"
105
+ end
106
+ canonical = payload.fetch("canonical")
107
+ raise ArgumentError, "proposal #{relative} feature does not match" unless canonical["feature"] == feature
108
+
109
+ { "path" => relative, "fingerprint" => payload.fetch("canonical_fingerprint"), "model" => canonical }
110
+ rescue JSON::ParserError => e
111
+ raise ArgumentError, "proposal validator returned invalid JSON for #{relative}: #{e.message}"
112
+ end
113
+
114
+ def grouped_alternatives(entries, field)
115
+ groups = {}
116
+ entries.each do |entry|
117
+ present = entry["value"].key?(field)
118
+ value = present ? entry["value"][field] : nil
119
+ key = fingerprint({ "present" => present, "value" => value })
120
+ groups[key] ||= { "present" => present, "value" => value, "proposal_refs" => [] }
121
+ groups[key]["proposal_refs"] << entry["proposal_ref"]
122
+ end
123
+ groups.values.each { |item| item["proposal_refs"].sort! }
124
+ groups.values.sort_by { |item| fingerprint(item) }
125
+ end
126
+
127
+ def conflict_id(subject, field)
128
+ "CONFLICT-#{subject}-#{field}".gsub(/[^A-Za-z0-9_-]/, "-")
129
+ end
130
+
131
+ def merge_collection(collection, proposals, conflicts)
132
+ by_id = {}
133
+ proposals.each do |proposal|
134
+ Array(proposal["model"][collection]).each do |value|
135
+ id = value["id"].to_s
136
+ by_id[id] ||= []
137
+ by_id[id] << { "value" => value, "proposal_ref" => "#{proposal['path']}##{id}" }
138
+ end
139
+ end
140
+ merged = []
141
+ # Proposal files are sorted before union. Preserve the first appearance of
142
+ # every ID so business-order arrays remain semantic and fingerprint-stable;
143
+ # sorting IDs here would silently rewrite the model.
144
+ by_id.keys.each do |id|
145
+ entries = by_id[id]
146
+ variants = entries.group_by { |entry| fingerprint(entry["value"]) }
147
+ if variants.length == 1
148
+ merged << entries.first["value"]
149
+ next
150
+ end
151
+ fields = entries.flat_map { |entry| entry["value"].keys }.uniq.sort
152
+ fields.each do |field|
153
+ alternatives = grouped_alternatives(entries, field)
154
+ next if alternatives.length == 1
155
+
156
+ conflicts << {
157
+ "id" => conflict_id(id, field),
158
+ "subject_ref" => id,
159
+ "collection" => collection,
160
+ "field" => field,
161
+ "alternatives" => alternatives,
162
+ "status" => "unresolved"
163
+ }
164
+ end
165
+ # The whole subject stays out of merged while any field differs. This
166
+ # prevents an incomplete hybrid object from looking canonical.
167
+ end
168
+ merged
169
+ end
170
+
171
+ def detect_cross_collection_ids(proposals, conflicts)
172
+ locations = {}
173
+ proposals.each do |proposal|
174
+ COLLECTIONS.each do |collection|
175
+ Array(proposal["model"][collection]).each do |value|
176
+ id = value["id"].to_s
177
+ locations[id] ||= {}
178
+ locations[id][collection] ||= []
179
+ locations[id][collection] << "#{proposal['path']}##{id}"
180
+ end
181
+ end
182
+ end
183
+ collisions = Set.new
184
+ locations.each do |id, collections|
185
+ next unless collections.length > 1
186
+
187
+ collisions << id
188
+ conflicts << {
189
+ "id" => conflict_id(id, "collection"),
190
+ "subject_ref" => id,
191
+ "collection" => "cross_collection",
192
+ "field" => "collection",
193
+ "alternatives" => collections.keys.sort.map { |collection| { "present" => true, "value" => collection, "proposal_refs" => collections[collection].uniq.sort } },
194
+ "status" => "unresolved"
195
+ }
196
+ end
197
+ collisions
198
+ end
199
+
200
+ def build_aggregate_payload(feature, proposals, revision)
201
+ conflicts = []
202
+ merged = {
203
+ "schema_version" => 1,
204
+ "feature" => feature
205
+ }
206
+ collisions = detect_cross_collection_ids(proposals, conflicts)
207
+ COLLECTIONS.each do |collection|
208
+ merged[collection] = merge_collection(collection, proposals, conflicts).reject { |value| collisions.include?(value["id"].to_s) }
209
+ end
210
+ REFERENCE_LISTS.each do |key|
211
+ merged[key] = proposals.flat_map { |proposal| Array(proposal["model"][key]) }.uniq
212
+ end
213
+ source_registry_groups = proposals.group_by { |proposal| proposal["model"]["source_registry_ref"] }
214
+ source_registry_refs = source_registry_groups.keys
215
+ if source_registry_refs.length == 1
216
+ merged["source_registry_ref"] = source_registry_refs.first
217
+ else
218
+ conflicts << {
219
+ "id" => "CONFLICT-BUSINESS-MODEL-source_registry_ref",
220
+ "subject_ref" => "BUSINESS-MODEL",
221
+ "collection" => "root",
222
+ "field" => "source_registry_ref",
223
+ "alternatives" => source_registry_refs.sort.map do |value|
224
+ { "present" => true, "value" => value, "proposal_refs" => source_registry_groups[value].map { |proposal| proposal["path"] }.sort }
225
+ end,
226
+ "status" => "unresolved"
227
+ }
228
+ end
229
+ proposal_meta = proposals.map { |proposal| proposal.slice("path", "fingerprint") }
230
+ payload = {
231
+ "schema_version" => 1,
232
+ "feature" => feature,
233
+ "revision" => revision,
234
+ "proposal_set_fingerprint" => fingerprint(proposal_meta),
235
+ "proposals" => proposal_meta,
236
+ "merged" => merged,
237
+ "conflicts" => conflicts.sort_by { |conflict| conflict["id"] }
238
+ }
239
+ payload["content_fingerprint"] = fingerprint(payload.reject { |key, _| %w[revision content_fingerprint].include?(key) })
240
+ payload
241
+ end
242
+
243
+ def load_existing_aggregate(path, feature)
244
+ return nil unless File.file?(path)
245
+
246
+ value = JSON.parse(File.read(path))
247
+ valid = value.is_a?(Hash) && value["schema_version"] == 1 && value["feature"] == feature && value["revision"].is_a?(Integer)
248
+ raise ArgumentError, "invalid existing aggregate" unless valid
249
+
250
+ value
251
+ rescue JSON::ParserError
252
+ raise ArgumentError, "invalid existing aggregate"
253
+ end
254
+
255
+ def load_delta(root, feature, raw)
256
+ raise ArgumentError, "candidate delta path is required" if raw.to_s.empty?
257
+ path = File.expand_path(raw, root)
258
+ feature_root = File.expand_path(File.join(".planning", "business-model", feature, "candidates"), root)
259
+ raise ArgumentError, "candidate delta is missing" unless File.file?(path)
260
+ path = File.realpath(path)
261
+ feature_root = File.realpath(feature_root)
262
+ raise ArgumentError, "candidate delta must be inside the feature candidate directory" unless path.start_with?("#{feature_root}#{File::SEPARATOR}")
263
+ value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
264
+ raise ArgumentError, "candidate delta must be a mapping" unless value.is_a?(Hash)
265
+ required = %w[schema_version feature aggregate_revision aggregate_fingerprint source_registry_fingerprint conflict_ids assumption_ids proposed_by operations]
266
+ missing = required.reject { |key| value.key?(key) }
267
+ raise ArgumentError, "candidate delta missing #{missing.join(', ')}" unless missing.empty?
268
+ raise ArgumentError, "candidate delta schema_version is invalid" unless value["schema_version"] == 1
269
+ raise ArgumentError, "candidate delta feature mismatch" unless value["feature"] == feature
270
+ raise ArgumentError, "candidate delta must target a conflict or assumption" if Array(value["conflict_ids"]).empty? && Array(value["assumption_ids"]).empty?
271
+ operations = Array(value["operations"])
272
+ raise ArgumentError, "candidate delta operations are required" if operations.empty?
273
+ operations.each_with_index do |operation, index|
274
+ raise ArgumentError, "candidate delta operation #{index} must be a mapping" unless operation.is_a?(Hash)
275
+ %w[op collection target_id].each { |key| raise ArgumentError, "candidate delta operation #{index} missing #{key}" if operation[key].to_s.empty? }
276
+ raise ArgumentError, "candidate delta operation #{index} has invalid op" unless %w[add replace remove].include?(operation["op"].to_s)
277
+ raise ArgumentError, "candidate delta operation #{index} has invalid collection" unless COLLECTIONS.include?(operation["collection"].to_s)
278
+ if %w[add replace].include?(operation["op"].to_s)
279
+ value_object = operation["value"]
280
+ raise ArgumentError, "candidate delta operation #{index} requires a complete value object" unless value_object.is_a?(Hash) && value_object["id"].to_s == operation["target_id"].to_s
281
+ elsif operation.key?("value") && !operation["value"].nil?
282
+ raise ArgumentError, "remove operation cannot carry a value"
283
+ end
284
+ end
285
+ [value, Pathname.new(path).relative_path_from(Pathname.new(File.realpath(root))).to_s]
286
+ rescue Psych::Exception => e
287
+ raise ArgumentError, "invalid candidate delta YAML: #{e.message}"
288
+ end
289
+
290
+ def source_registry_fingerprint(root, feature, aggregate)
291
+ registry_ref = aggregate.dig("merged", "source_registry_ref").to_s
292
+ return "" if registry_ref.empty?
293
+ path = if registry_ref.start_with?(".planning/")
294
+ File.expand_path(registry_ref, root)
295
+ else
296
+ File.expand_path(registry_ref, feature_dir(root, feature))
297
+ end
298
+ return "" unless File.file?(path)
299
+ "sha256:#{Digest::SHA256.file(path).hexdigest}"
300
+ end
301
+
302
+ def load_canonical(root, feature)
303
+ path = File.join(feature_dir(root, feature), "canonical.json")
304
+ raise ArgumentError, "canonical is missing" unless File.file?(path)
305
+ value = JSON.parse(File.read(path))
306
+ raise ArgumentError, "canonical feature mismatch" unless value.is_a?(Hash) && value["feature"] == feature
307
+ [path, value]
308
+ rescue JSON::ParserError => e
309
+ raise ArgumentError, "invalid canonical JSON: #{e.message}"
310
+ end
311
+
312
+ def canonical_fingerprint_from_python(root, path)
313
+ Tempfile.create(["business-meta", ".json"]) do |output|
314
+ stdout, stderr, status = Open3.capture3("python3", VALIDATOR, "--proposal", path, "--canonical-out", output.path)
315
+ payload = JSON.parse(stdout)
316
+ raise ArgumentError, "canonical validation failed: #{stderr.strip}" unless status.success? && payload["valid"] == true
317
+ [payload.fetch("canonical_fingerprint"), payload.fetch("canonical")]
318
+ end
319
+ rescue JSON::ParserError => e
320
+ raise ArgumentError, "canonical validator returned invalid JSON: #{e.message}"
321
+ end
322
+
323
+ def meta(root:, feature:, expected_revision:, write:)
324
+ path, canonical = load_canonical(root, feature)
325
+ fingerprint_value, = canonical_fingerprint_from_python(root, path)
326
+ aggregate = load_existing_aggregate(File.join(feature_dir(root, feature), "aggregate.json"), feature)
327
+ aggregate_revision = aggregate ? aggregate["revision"].to_i : expected_revision.to_i
328
+ raise ArgumentError, "canonical revision mismatch" unless aggregate.nil? || aggregate_revision == expected_revision.to_i
329
+ source_fingerprint = aggregate ? source_registry_fingerprint(root, feature, aggregate) : ""
330
+ metadata = {
331
+ "schema_version" => 1,
332
+ "feature" => feature,
333
+ "prototype_fingerprint" => fingerprint_value,
334
+ "source_document_fingerprint" => source_fingerprint,
335
+ "aggregate_revision" => aggregate_revision,
336
+ "checker_summary" => { "structural" => "passed", "closure" => "required_before_approval" },
337
+ "generated_at" => Time.now.utc.iso8601
338
+ }
339
+ target = File.join(feature_dir(root, feature), "canonical.meta.json")
340
+ DevflowControlIO.atomic_write(target, JSON.pretty_generate(metadata) + "\n") if write
341
+ { "ok" => true, "action" => "meta", "meta" => metadata, "path" => relative_to_root(root, target) }
342
+ end
343
+
344
+ def approve(root:, feature:, expected_revision:, approved_by:, runtime_confirmed:, write:)
345
+ raise ArgumentError, "runtime confirmation is required" unless runtime_confirmed
346
+ raise ArgumentError, "approved_by is required" if approved_by.to_s.empty?
347
+ path, canonical = load_canonical(root, feature)
348
+ fingerprint_value, = canonical_fingerprint_from_python(root, path)
349
+ aggregate = load_existing_aggregate(File.join(feature_dir(root, feature), "aggregate.json"), feature)
350
+ revision = aggregate ? aggregate["revision"].to_i : expected_revision.to_i
351
+ raise ArgumentError, "canonical revision mismatch" unless revision == expected_revision.to_i
352
+ source_fingerprint = aggregate ? source_registry_fingerprint(root, feature, aggregate) : ""
353
+ approval = {
354
+ "schema_version" => 1,
355
+ "feature" => feature,
356
+ "status" => "approved",
357
+ "approved_by" => approved_by,
358
+ "approved_at" => Time.now.utc.iso8601,
359
+ "prototype_fingerprint" => fingerprint_value,
360
+ "source_document_fingerprint" => source_fingerprint,
361
+ "revision" => revision,
362
+ "canonical_screens" => [],
363
+ "confirmed_screens" => []
364
+ }
365
+ target = File.join(feature_dir(root, feature), "approval.json")
366
+ DevflowControlIO.atomic_write(target, JSON.pretty_generate(approval) + "\n") if write
367
+ { "ok" => true, "action" => "approve", "approval" => approval, "path" => relative_to_root(root, target) }
368
+ end
369
+
370
+ def approval_status(root:, feature:, expected_revision:)
371
+ approval_path = File.join(feature_dir(root, feature), "approval.json")
372
+ raise ArgumentError, "approval is missing" unless File.file?(approval_path)
373
+ approval = JSON.parse(File.read(approval_path))
374
+ canonical_path, = load_canonical(root, feature)
375
+ fingerprint_value, = canonical_fingerprint_from_python(root, canonical_path)
376
+ aggregate = load_existing_aggregate(File.join(feature_dir(root, feature), "aggregate.json"), feature)
377
+ source_fingerprint = aggregate ? source_registry_fingerprint(root, feature, aggregate) : ""
378
+ current = approval["status"] == "approved" && approval["prototype_fingerprint"] == fingerprint_value && approval["source_document_fingerprint"].to_s == source_fingerprint.to_s && approval["revision"].to_i == expected_revision.to_i
379
+ { "ok" => true, "action" => "approval-status", "current" => current, "approval" => approval, "actual" => { "prototype_fingerprint" => fingerprint_value, "source_document_fingerprint" => source_fingerprint, "revision" => expected_revision } }
380
+ rescue JSON::ParserError => e
381
+ raise ArgumentError, "invalid approval JSON: #{e.message}"
382
+ end
383
+
384
+ def write_decision(root, feature, decision)
385
+ path = File.join(feature_dir(root, feature), "business-decisions.json")
386
+ existing = if File.file?(path)
387
+ JSON.parse(File.read(path))
388
+ else
389
+ { "schema_version" => 1, "feature" => feature, "revision" => 0, "decisions" => [] }
390
+ end
391
+ existing["revision"] = existing.fetch("revision", 0).to_i + 1
392
+ existing["decisions"] = Array(existing["decisions"]) + [decision.merge("revision" => existing["revision"])]
393
+ existing["fingerprint"] = fingerprint(existing.reject { |key, _| key == "fingerprint" })
394
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(existing) + "\n")
395
+ [path, existing]
396
+ rescue JSON::ParserError => e
397
+ raise ArgumentError, "invalid business decision registry: #{e.message}"
398
+ end
399
+
400
+ def adjudicate(root:, feature:, expected_revision:, delta_path:, decision_id:, decided_by:, accept:, write:)
401
+ directory = feature_dir(root, feature)
402
+ aggregate_path = File.join(directory, "aggregate.json")
403
+ aggregate = load_existing_aggregate(aggregate_path, feature)
404
+ raise ArgumentError, "aggregate is missing" unless aggregate
405
+ delta, relative_delta = load_delta(root, feature, delta_path)
406
+ raise ArgumentError, "aggregate revision mismatch" unless expected_revision == aggregate["revision"]
407
+ raise ArgumentError, "candidate delta aggregate revision is stale" unless delta["aggregate_revision"].to_i == aggregate["revision"].to_i
408
+ raise ArgumentError, "candidate delta aggregate fingerprint is stale" unless delta["aggregate_fingerprint"].to_s == aggregate["content_fingerprint"].to_s
409
+ actual_source = source_registry_fingerprint(root, feature, aggregate)
410
+ if !actual_source.empty? && delta["source_registry_fingerprint"].to_s != actual_source
411
+ raise ArgumentError, "candidate delta source registry fingerprint is stale"
412
+ end
413
+ current_conflicts = Array(aggregate["conflicts"]).select { |item| item["status"].to_s != "resolved" }.map { |item| item["id"].to_s }
414
+ targeted = Array(delta["conflict_ids"]).map(&:to_s) + Array(delta["assumption_ids"]).map(&:to_s)
415
+ raise ArgumentError, "candidate delta targets a changed conflict set" unless (targeted & current_conflicts).sort == Array(delta["conflict_ids"]).map(&:to_s).sort
416
+
417
+ decision = {
418
+ "id" => decision_id.to_s,
419
+ "kind" => accept ? "accept_candidate_delta" : "reject_candidate_delta",
420
+ "decided_by" => decided_by.to_s,
421
+ "delta_path" => relative_delta,
422
+ "aggregate_revision" => aggregate["revision"],
423
+ "aggregate_fingerprint" => aggregate["content_fingerprint"],
424
+ "conflict_ids" => Array(delta["conflict_ids"]),
425
+ "assumption_ids" => Array(delta["assumption_ids"]),
426
+ "created_at" => Time.now.utc.iso8601
427
+ }
428
+ raise ArgumentError, "decision id and decided_by are required" if decision_id.to_s.empty? || decided_by.to_s.empty?
429
+ return { "ok" => true, "action" => "adjudicate", "changed" => false, "decision" => write_decision(root, feature, decision)[1] } unless accept
430
+
431
+ updated = JSON.parse(JSON.generate(aggregate))
432
+ delta["operations"].each do |operation|
433
+ collection = updated.fetch("merged").fetch(operation.fetch("collection"))
434
+ index = collection.index { |item| item["id"].to_s == operation["target_id"].to_s }
435
+ case operation["op"]
436
+ when "add"
437
+ raise ArgumentError, "candidate add target already exists" if index
438
+ collection << operation.fetch("value")
439
+ when "replace"
440
+ raise ArgumentError, "candidate replace target does not exist" unless index
441
+ collection[index] = operation.fetch("value")
442
+ when "remove"
443
+ raise ArgumentError, "candidate remove target does not exist" unless index
444
+ collection.delete_at(index)
445
+ end
446
+ end
447
+ updated["conflicts"].each { |conflict| conflict["status"] = "resolved" if Array(delta["conflict_ids"]).include?(conflict["id"]) }
448
+ updated["merged"]["assumptions"] = [] if Array(delta["assumption_ids"]).any?
449
+ updated["revision"] = aggregate["revision"] + 1
450
+ updated["content_fingerprint"] = fingerprint(updated.reject { |key, _| %w[revision content_fingerprint].include?(key) })
451
+ decision["resulting_aggregate_revision"] = updated["revision"]
452
+ if write
453
+ DevflowControlIO.atomic_write(aggregate_path, JSON.pretty_generate(updated) + "\n")
454
+ write_decision(root, feature, decision)
455
+ end
456
+ { "ok" => true, "action" => "adjudicate", "changed" => true, "aggregate" => updated, "decision" => decision, "path" => relative_to_root(root, aggregate_path) }
457
+ end
458
+
459
+ def aggregate(root:, feature:, expected_revision:, requested_proposals:, write:)
460
+ directory = feature_dir(root, feature)
461
+ path = File.join(directory, "aggregate.json")
462
+ existing = load_existing_aggregate(path, feature)
463
+ current_revision = existing ? existing["revision"] : 0
464
+ raise ArgumentError, "aggregate revision mismatch: expected #{expected_revision}, current #{current_revision}" unless expected_revision == current_revision
465
+
466
+ proposals = proposal_paths(root, feature, requested_proposals).map { |absolute, relative| validate_proposal(absolute, relative, feature) }
467
+ candidate = build_aggregate_payload(feature, proposals, current_revision + 1)
468
+ if existing && existing["content_fingerprint"] == candidate["content_fingerprint"]
469
+ return { "ok" => true, "action" => "aggregate", "changed" => false, "aggregate" => existing, "path" => relative_to_root(root, path) }
470
+ end
471
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(candidate) + "\n") if write
472
+ { "ok" => true, "action" => "aggregate", "changed" => true, "aggregate" => candidate, "path" => relative_to_root(root, path) }
473
+ end
474
+
475
+ def canonical(root:, feature:, expected_revision:, write:)
476
+ directory = feature_dir(root, feature)
477
+ aggregate_path = File.join(directory, "aggregate.json")
478
+ aggregate = load_existing_aggregate(aggregate_path, feature)
479
+ raise ArgumentError, "aggregate is missing" unless aggregate
480
+ raise ArgumentError, "aggregate revision mismatch: expected #{expected_revision}, current #{aggregate['revision']}" unless expected_revision == aggregate["revision"]
481
+ unresolved = Array(aggregate["conflicts"]).select { |conflict| conflict["status"] != "resolved" }
482
+ raise ArgumentError, "canonical build requires all conflicts to be resolved" if unresolved.any?
483
+
484
+ model = JSON.parse(JSON.generate(aggregate.fetch("merged")))
485
+ assumptions = Array(model.delete("assumptions"))
486
+ raise ArgumentError, "canonical build requires assumptions to be adjudicated by Core" if assumptions.any?
487
+
488
+ Tempfile.create(["business-canonical", ".json"]) do |proposal_file|
489
+ proposal_file.write(JSON.generate(model))
490
+ proposal_file.flush
491
+ Tempfile.create(["business-canonical-out", ".json"]) do |canonical_file|
492
+ stdout, stderr, status = Open3.capture3("python3", VALIDATOR, "--proposal", proposal_file.path, "--canonical-out", canonical_file.path)
493
+ payload = JSON.parse(stdout)
494
+ unless status.success? && payload["valid"] == true
495
+ details = Array(payload["errors"]).map { |error| error["code"] }.uniq.join(", ")
496
+ raise ArgumentError, "canonical model is invalid: #{details.empty? ? stderr.strip : details}"
497
+ end
498
+ target = File.join(directory, "canonical.json")
499
+ DevflowControlIO.atomic_write(target, File.read(canonical_file.path)) if write
500
+ return {
501
+ "ok" => true,
502
+ "action" => "canonical",
503
+ "canonical_fingerprint" => payload["canonical_fingerprint"],
504
+ "canonical" => payload["canonical"],
505
+ "path" => relative_to_root(root, target),
506
+ "aggregate_revision" => aggregate["revision"]
507
+ }
508
+ end
509
+ end
510
+ rescue JSON::ParserError => e
511
+ raise ArgumentError, "canonical validator returned invalid JSON: #{e.message}"
512
+ end
513
+
514
+ def relative_to_root(root, path)
515
+ Pathname.new(path).relative_path_from(Pathname.new(File.realpath(root))).to_s
516
+ end
517
+
518
+ def execute(root:, action:, feature:, expected_revision:, requested_proposals:, write:, delta_path: nil, decision_id: nil, decided_by: nil, accept: false, approved_by: nil, runtime_confirmed: false)
519
+ root = File.realpath(root)
520
+ lock = DevflowControlIO.acquire_project_lock(root)
521
+ case action
522
+ when "aggregate"
523
+ aggregate(root: root, feature: feature, expected_revision: expected_revision, requested_proposals: requested_proposals, write: write)
524
+ when "canonical"
525
+ canonical(root: root, feature: feature, expected_revision: expected_revision, write: write)
526
+ when "adjudicate"
527
+ adjudicate(root: root, feature: feature, expected_revision: expected_revision, delta_path: delta_path, decision_id: decision_id, decided_by: decided_by, accept: accept, write: write)
528
+ when "meta"
529
+ meta(root: root, feature: feature, expected_revision: expected_revision, write: write)
530
+ when "approve"
531
+ approve(root: root, feature: feature, expected_revision: expected_revision, approved_by: approved_by, runtime_confirmed: runtime_confirmed, write: write)
532
+ when "approval-status"
533
+ approval_status(root: root, feature: feature, expected_revision: expected_revision)
534
+ else
535
+ raise ArgumentError, "action must be aggregate or canonical"
536
+ end
537
+ rescue ArgumentError, DevflowControlIO::InvalidControlPath => e
538
+ { "ok" => false, "reason" => "business_model_commit_failed", "message" => e.message }
539
+ ensure
540
+ DevflowControlIO.release_lock(lock)
541
+ end
542
+ end
543
+
544
+ if $PROGRAM_NAME == __FILE__
545
+ options = { root: ".", proposals: [], write: false, accept: false, runtime_confirmed: false }
546
+ OptionParser.new do |opts|
547
+ opts.on("--root PATH") { |value| options[:root] = value }
548
+ opts.on("--action ACTION") { |value| options[:action] = value }
549
+ opts.on("--feature NAME") { |value| options[:feature] = value }
550
+ opts.on("--expected-revision N", Integer) { |value| options[:expected_revision] = value }
551
+ opts.on("--proposal PATH") { |value| options[:proposals] << value }
552
+ opts.on("--delta PATH") { |value| options[:delta_path] = value }
553
+ opts.on("--decision-id ID") { |value| options[:decision_id] = value }
554
+ opts.on("--decided-by USER") { |value| options[:decided_by] = value }
555
+ opts.on("--accept") { options[:accept] = true }
556
+ opts.on("--approved-by USER") { |value| options[:approved_by] = value }
557
+ opts.on("--runtime-confirmed") { options[:runtime_confirmed] = true }
558
+ opts.on("--write") { options[:write] = true }
559
+ opts.on("--json") { options[:json] = true }
560
+ end.parse!
561
+ abort("--action, --feature and --expected-revision are required") unless options[:action] && options[:feature] && !options[:expected_revision].nil?
562
+ abort("--delta, --decision-id and --decided-by are required for adjudicate") if options[:action] == "adjudicate" && [options[:delta_path], options[:decision_id], options[:decided_by]].any?(&:nil?)
563
+ abort("--approved-by and --runtime-confirmed are required for approve") if options[:action] == "approve" && [options[:approved_by], options[:runtime_confirmed]].any? { |value| value == false || value.nil? }
564
+ payload = BusinessModelCommit.execute(
565
+ root: options[:root], action: options[:action], feature: options[:feature], expected_revision: options[:expected_revision],
566
+ requested_proposals: options[:proposals], write: options[:write], delta_path: options[:delta_path], decision_id: options[:decision_id], decided_by: options[:decided_by], accept: options[:accept], approved_by: options[:approved_by], runtime_confirmed: options[:runtime_confirmed]
567
+ )
568
+ puts JSON.pretty_generate(payload)
569
+ exit(payload["ok"] ? 0 : 1)
570
+ end
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "optparse"
6
+ require "yaml"
7
+ require_relative "gate_result"
8
+
9
+ module BusinessModelGate
10
+ POLICY_PATH = File.expand_path("../contracts/business-model-policy.yaml", __dir__)
11
+ TRANSITIONS = %w[modeling adjudicating freeze consume_downstream].freeze
12
+
13
+ module_function
14
+
15
+ def load_policy(path = POLICY_PATH)
16
+ value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
17
+ raise ArgumentError, "business model policy must be a mapping" unless value.is_a?(Hash)
18
+ transitions = Array(value["transition_kinds"])
19
+ raise ArgumentError, "business model policy transition kinds are invalid" unless transitions.sort == TRANSITIONS.sort
20
+ value
21
+ rescue Psych::Exception => e
22
+ raise ArgumentError, "invalid business model policy: #{e.message}"
23
+ end
24
+
25
+ def verdict_for(policy, code, transition_kind)
26
+ transition = transition_kind.to_s
27
+ raise ArgumentError, "unknown business model transition kind: #{transition}" unless TRANSITIONS.include?(transition)
28
+ rule = Array(policy["rules"]).find { |item| Array(item["codes"]).map(&:to_s).include?(code.to_s) }
29
+ (rule && rule[transition]) || policy.fetch("default").fetch(transition)
30
+ end
31
+
32
+ def evaluate(violations:, transition_kind:, gate_id: "business-model-closure", step_id: "business-model")
33
+ policy = load_policy
34
+ transition = transition_kind.to_s
35
+ raise ArgumentError, "unknown business model transition kind: #{transition}" unless TRANSITIONS.include?(transition)
36
+ items = Array(violations).select { |item| item.is_a?(Hash) }
37
+ return GateResult.allow(gate_id: gate_id, step_id: step_id, evidence: { "checker" => "business-closure-check", "violation_count" => 0 }) if items.empty?
38
+
39
+ decisions = items.map do |item|
40
+ code = item["code"].to_s
41
+ { "code" => code, "subject_ref" => item["subject_ref"], "verdict" => verdict_for(policy, code, transition_kind), "facts" => item["facts"] || {} }
42
+ end
43
+ verdict = if decisions.any? { |item| item["verdict"] == "block_unsafe" }
44
+ "block_unsafe"
45
+ else
46
+ "await_user"
47
+ end
48
+ errors = decisions.map do |item|
49
+ { "code" => item["code"], "message" => "business model closure violation", "subject_ref" => item["subject_ref"], "facts" => item["facts"] }
50
+ end
51
+ if verdict == "await_user"
52
+ GateResult.await_user(
53
+ gate_id: gate_id,
54
+ step_id: step_id,
55
+ errors: errors,
56
+ recovery: { "kind" => "user_adjudication", "transition_kind" => transition_kind },
57
+ evidence: { "checker" => "business-closure-check", "violations" => decisions },
58
+ )
59
+ else
60
+ GateResult.block(
61
+ gate_id: gate_id,
62
+ step_id: step_id,
63
+ code: "BUSINESS_MODEL_GATE_BLOCKED",
64
+ message: "business model cannot enter this transition",
65
+ details: { "violations" => errors, "transition_kind" => transition_kind },
66
+ evidence: { "checker" => "business-closure-check", "violations" => decisions },
67
+ )
68
+ end
69
+ end
70
+ end
71
+
72
+ if __FILE__ == $PROGRAM_NAME
73
+ options = { transition_kind: nil, gate_id: "business-model-closure", step_id: "business-model" }
74
+ OptionParser.new do |parser|
75
+ parser.on("--transition-kind KIND") { |value| options[:transition_kind] = value }
76
+ parser.on("--gate-id ID") { |value| options[:gate_id] = value }
77
+ parser.on("--step-id ID") { |value| options[:step_id] = value }
78
+ end.parse!
79
+
80
+ begin
81
+ raise ArgumentError, "--transition-kind is required" if options[:transition_kind].to_s.empty?
82
+ input = JSON.parse($stdin.read)
83
+ violations = input.is_a?(Array) ? input : input.fetch("violations", [])
84
+ puts JSON.pretty_generate(BusinessModelGate.evaluate(violations: violations, transition_kind: options[:transition_kind], gate_id: options[:gate_id], step_id: options[:step_id]))
85
+ rescue StandardError => error
86
+ puts JSON.pretty_generate(GateResult.block(gate_id: options[:gate_id], step_id: options[:step_id], code: "BUSINESS_GATE_POLICY_INVALID", message: error.message))
87
+ exit 1
88
+ end
89
+ end