@guanlin4924/devflow-pi 0.1.15 → 0.1.17

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 (30) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +425 -54
  3. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +3 -2
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_assessment.rb +458 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +256 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +155 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +176 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +46 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +94 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +2 -1
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +646 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/runtime_context_collector.rb +210 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/stale_check.rb +56 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/static_runtime_context_collector.rb +518 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +140 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workload_constraint.rb +57 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +88 -2
  19. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -2
  20. package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +17 -1
  21. package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +37 -0
  22. package/plugins/devflow-design/skills/workflow-architecture-feasibility/agents/openai.yaml +4 -0
  23. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +7 -2
  24. package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -1
  25. package/plugins/devflow-requirements/skills/requirement-feasibility-review/SKILL.md +4 -0
  26. package/plugins/devflow-requirements/skills/workflow-domain-modeling/SKILL.md +4 -0
  27. package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +4 -0
  28. package/plugins/devflow-requirements/skills/workflow-requirement-triage/SKILL.md +4 -0
  29. package/src/cli.ts +2 -1
  30. package/src/runtime.ts +2 -1
@@ -0,0 +1,646 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Core-owned requirement governance graph. Skills may submit a bounded delta,
5
+ # but only this writer assigns IDs, computes fingerprints/impact, and persists
6
+ # canonical graph and derived views under the project-wide control-plane lock.
7
+ require "digest"
8
+ require "json"
9
+ require "optparse"
10
+ require "pathname"
11
+ require "set"
12
+ require_relative "control_io"
13
+ require_relative "architecture_constraints"
14
+
15
+ module RequirementGraph
16
+ # v3 adds the two narrowly-scoped architecture inputs. Existing v1/v2
17
+ # graphs are migrated in memory and re-written only by this Core writer.
18
+ SCHEMA_VERSION = 3
19
+ SUPPORTED_SCHEMA_VERSIONS = [1, 2, SCHEMA_VERSION].freeze
20
+ ROOT = ".planning/requirements"
21
+ KINDS = {
22
+ "source" => "SRC", "code" => "CODE", "term" => "TERM", "object" => "OBJECT",
23
+ "behavior" => "BEH", "requirement" => "REQ", "rule" => "BR", "boundary" => "BC",
24
+ "slice" => "SL", "risk" => "RISK", "decision" => "DECISION", "acceptance" => "AC",
25
+ "api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC"
26
+ }.freeze
27
+ VIEW_NAMES = %w[evidence definitions behavior-graph requirement-graph slices acceptance traceability].freeze
28
+ FEATURE_INVALID = /[\\\/\x00-\x1F\x7F]/
29
+ RESERVED_KEYS = %w[id status revision passed confirmed graph_revision stale_fingerprint severity risk_level priority p0 high_risk].freeze
30
+
31
+ module_function
32
+
33
+ def valid_feature?(value)
34
+ value.is_a?(String) && !value.strip.empty? && !value.match?(FEATURE_INVALID)
35
+ end
36
+
37
+ def graph_path(root, feature)
38
+ raise ArgumentError, "invalid feature" unless valid_feature?(feature)
39
+ relative = File.join(ROOT, feature, "governance.json")
40
+ root_real = File.realpath(root)
41
+ absolute = File.expand_path(relative, root_real)
42
+ raise ArgumentError, "graph path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
43
+ [absolute, relative]
44
+ end
45
+
46
+ def feature_dir(root, feature)
47
+ path, = graph_path(root, feature)
48
+ File.dirname(path)
49
+ end
50
+
51
+ def empty_graph(feature)
52
+ { "schema_version" => SCHEMA_VERSION, "feature" => feature, "revision" => 0,
53
+ "nodes" => [], "edges" => [], "stale" => [], "views" => {}, "audit" => [] }
54
+ end
55
+
56
+ def canonical_json(value)
57
+ JSON.generate(value)
58
+ end
59
+
60
+ def fingerprint(value)
61
+ Digest::SHA256.hexdigest(canonical_json(value))
62
+ end
63
+
64
+ def normalize_legacy_graph(graph)
65
+ graph["schema_version"] = SCHEMA_VERSION
66
+ graph["views"] ||= {}
67
+ graph["audit"] ||= []
68
+ graph["stale"] = Array(graph["stale"]).map do |entry|
69
+ entry.is_a?(Hash) ? entry : { "node_id" => entry.to_s, "state" => "needs_recheck", "reason" => "legacy_unknown" }
70
+ end
71
+ Array(graph["nodes"]).each { |node| node["fingerprint"] ||= fingerprint(node.reject { |key, _| key == "fingerprint" }) if node.is_a?(Hash) }
72
+ graph
73
+ end
74
+
75
+ def load_graph(root, feature)
76
+ path, relative = graph_path(root, feature)
77
+ return [empty_graph(feature), path, relative] unless File.exist?(path)
78
+ parsed = JSON.parse(File.read(path))
79
+ valid_shape = parsed.is_a?(Hash) && SUPPORTED_SCHEMA_VERSIONS.include?(parsed["schema_version"]) && parsed["feature"] == feature &&
80
+ parsed["revision"].is_a?(Integer) && parsed["nodes"].is_a?(Array) && parsed["edges"].is_a?(Array) && parsed["stale"].is_a?(Array)
81
+ raise ArgumentError, "invalid governance graph" unless valid_shape
82
+ [normalize_legacy_graph(parsed), path, relative]
83
+ rescue JSON::ParserError
84
+ raise ArgumentError, "invalid governance graph"
85
+ end
86
+
87
+ def contains_reserved_key?(value)
88
+ case value
89
+ when Hash
90
+ value.any? { |key, child| RESERVED_KEYS.include?(key.to_s) || contains_reserved_key?(child) }
91
+ when Array then value.any? { |child| contains_reserved_key?(child) }
92
+ else false
93
+ end
94
+ end
95
+
96
+ def normalize_data(value)
97
+ raise ArgumentError, "delta data must be an object" unless value.is_a?(Hash)
98
+ raise ArgumentError, "delta data cannot set reserved keys: #{RESERVED_KEYS.join(", ")}" if contains_reserved_key?(value)
99
+ value
100
+ end
101
+
102
+ def next_id(graph, prefix)
103
+ maximum = graph["nodes"].map do |node|
104
+ match = node.is_a?(Hash) && node["id"].is_a?(String) ? node["id"].match(/\A#{Regexp.escape(prefix)}-(\d+)\z/) : nil
105
+ match && match[1].to_i
106
+ end.compact.max || 0
107
+ format("%s-%04d", prefix, maximum + 1)
108
+ end
109
+
110
+ def node_ids(graph)
111
+ graph["nodes"].map { |node| node["id"] if node.is_a?(Hash) && node["id"].is_a?(String) }.compact.to_set
112
+ end
113
+
114
+ def set_node_fingerprint!(node)
115
+ node["fingerprint"] = fingerprint(node.reject { |key, _| key == "fingerprint" })
116
+ end
117
+
118
+ def downstream(graph, id, mode)
119
+ direct = Array(graph["edges"]).map { |edge| edge["to"] if edge.is_a?(Hash) && edge["from"] == id }.compact
120
+ return direct.uniq if mode == "one_hop"
121
+ seen = Set.new([id])
122
+ queue = direct.dup
123
+ result = []
124
+ until queue.empty?
125
+ current = queue.shift
126
+ next if seen.include?(current)
127
+ seen << current
128
+ result << current
129
+ Array(graph["edges"]).each { |edge| queue << edge["to"] if edge.is_a?(Hash) && edge["from"] == current }
130
+ end
131
+ result
132
+ end
133
+
134
+ def mark_stale!(graph, upstream, affected)
135
+ affected.each do |node_id|
136
+ node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node_id }
137
+ graph["stale"] << { "node_id" => node_id, "state" => "needs_recheck", "reason" => "upstream_changed",
138
+ "upstream" => upstream, "stale_fingerprint" => node && node["fingerprint"] }
139
+ end
140
+ graph["stale"].uniq! { |entry| [entry["node_id"], entry["upstream"], entry["stale_fingerprint"]] }
141
+ graph["stale"].each { |entry| entry["graph_revision"] = graph["revision"] + 1 }
142
+ end
143
+
144
+ # Re-submitting a changed downstream node is the explicit Core-mediated
145
+ # acknowledgement that its stale fingerprint was reviewed. It may itself
146
+ # invalidate later consumers, so only that node's stale marker is cleared.
147
+ def clear_stale_for!(graph, node_id)
148
+ graph["stale"].reject! { |entry| entry.is_a?(Hash) && entry["node_id"] == node_id }
149
+ end
150
+
151
+ def proposal(graph, delta, allowed_kinds = nil)
152
+ raise ArgumentError, "delta must be an object" unless delta.is_a?(Hash)
153
+ case delta["op"]
154
+ when "add"
155
+ kind = delta["kind"]
156
+ prefix = KINDS[kind]
157
+ raise ArgumentError, "unsupported node kind" unless prefix
158
+ raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
159
+ data = normalize_data(delta["data"])
160
+ node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
161
+ candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node)
162
+ raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
163
+ set_node_fingerprint!(node)
164
+ graph["nodes"] << node
165
+ { "operation" => "add", "node" => node }
166
+ when "link"
167
+ from, to, relation = delta.values_at("from", "to", "relation")
168
+ raise ArgumentError, "link requires known from/to IDs" unless from.is_a?(String) && to.is_a?(String) && node_ids(graph).include?(from) && node_ids(graph).include?(to)
169
+ raise ArgumentError, "link relation is required" unless relation.is_a?(String) && relation.match?(/\A[a-z][a-z0-9_-]{0,63}\z/)
170
+ edge = { "from" => from, "to" => to, "relation" => relation }
171
+ raise ArgumentError, "duplicate graph edge" if graph["edges"].include?(edge)
172
+ graph["edges"] << edge
173
+ { "operation" => "link", "edge" => edge }
174
+ when "update"
175
+ id = delta["id"]
176
+ node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
177
+ raise ArgumentError, "update requires a known node id" unless node
178
+ if (%w[workload_shape workload_constraint].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
179
+ raise ArgumentError, "confirmed workload nodes and architecture decisions are immutable; add a new candidate and relink consumers"
180
+ end
181
+ data = normalize_data(delta["data"])
182
+ node["data"] = node.fetch("data", {}).merge(data)
183
+ candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node)
184
+ raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
185
+ set_node_fingerprint!(node)
186
+ clear_stale_for!(graph, id)
187
+ mode = delta["propagation"] || "full"
188
+ raise ArgumentError, "propagation must be one_hop or full" unless %w[one_hop full].include?(mode)
189
+ affected = downstream(graph, id, mode)
190
+ mark_stale!(graph, id, affected)
191
+ { "operation" => "update", "node" => node, "stale" => affected, "propagation" => mode }
192
+ else
193
+ raise ArgumentError, "delta op must be add, link, or update"
194
+ end
195
+ end
196
+
197
+ # Core-only 审核转换:candidate → confirmed,要求人工 CP 记录或运行证据。
198
+ def confirm(graph, node_id, evidence, confirmed_interactively: false)
199
+ node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node_id }
200
+ raise ArgumentError, "confirm requires a known node id" unless node
201
+ raise ArgumentError, "node is already confirmed" if node["status"] == "confirmed"
202
+ unless evidence.is_a?(Hash) && (evidence["cp_record"] || evidence["evidence_ref"])
203
+ raise ArgumentError, "confirm requires cp_record or evidence_ref"
204
+ end
205
+ if (%w[workload_shape workload_constraint].include?(node["kind"]) || architecture_control_node?(node)) && !confirmed_interactively
206
+ raise ArgumentError, "workload and architecture-control confirmation requires --confirmed-interactively from the Core confirmation dialog"
207
+ end
208
+ confirmation_errors = workload_confirmation_errors(graph, node, evidence) + architecture_decision_confirmation_errors(graph, node) + architecture_risk_confirmation_errors(graph, node)
209
+ raise ArgumentError, confirmation_errors.join("; ") if confirmation_errors.any?
210
+ node["status"] = "confirmed"
211
+ node["confirmation"] = { "origin" => "core", "evidence" => evidence }
212
+ set_node_fingerprint!(node)
213
+ clear_stale_for!(graph, node_id)
214
+ { "operation" => "confirm", "node" => node }
215
+ end
216
+
217
+ def present?(value)
218
+ !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
219
+ end
220
+
221
+ # 风险等级不是模型可填写的枚举,而由 Core 从图谱结构事实推导。
222
+ def high_risk_by_structure?(node)
223
+ data = node["data"].is_a?(Hash) ? node["data"] : {}
224
+ if node["kind"] == "behavior"
225
+ has_writes = present?(data["writes"]) || present?(data["side_effects"])
226
+ return true if has_writes && !present?(data["recovery_strategy"])
227
+ return true if data["concurrent"] == true && !present?(data["concurrency_control"])
228
+ end
229
+ if %w[requirement slice].include?(node["kind"])
230
+ scope = data["data_scope"]
231
+ return true if scope.is_a?(String) && !%w[current_org_only self self_only].include?(scope)
232
+ end
233
+ false
234
+ end
235
+
236
+ def derived_high_risk_ids(graph)
237
+ Array(graph["nodes"]).select { |node| node.is_a?(Hash) && high_risk_by_structure?(node) }.map { |node| node["id"] }
238
+ end
239
+
240
+ def required(errors, node, keys)
241
+ keys.each { |key| errors << "#{node["id"]}: missing #{key}" unless present?(node.dig("data", key)) }
242
+ end
243
+
244
+ def refs_known?(graph, refs, prefixes)
245
+ known = node_ids(graph)
246
+ Array(refs).all? { |ref| ref.is_a?(String) && known.include?(ref) && prefixes.any? { |prefix| ref.start_with?("#{prefix}-") } }
247
+ end
248
+
249
+ def incoming_edge?(graph, from, to, relation)
250
+ Array(graph["edges"]).any? do |edge|
251
+ edge.is_a?(Hash) && edge["from"] == from && edge["to"] == to && edge["relation"] == relation
252
+ end
253
+ end
254
+
255
+ # WSH/WLC are graph-owned facts, not a second unbounded document schema.
256
+ # The candidate validator owns field rules; this layer adds only the facts
257
+ # it can prove: graph references, traceability edges, and confirmation state.
258
+ def workload_node_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
259
+ return [] unless %w[workload_shape workload_constraint].include?(node["kind"])
260
+
261
+ data = node["data"].is_a?(Hash) ? node["data"] : {}
262
+ payload = data.merge("feature" => graph["feature"], "status" => node["status"])
263
+ errors =
264
+ if node["kind"] == "workload_shape"
265
+ ArchitectureConstraints.validate_shape(payload, allow_confirmed: true)
266
+ else
267
+ ArchitectureConstraints.validate_constraint(payload, allow_confirmed: true)
268
+ end
269
+ errors = errors.map { |error| "#{node["id"]}: #{error}" }
270
+
271
+ if node["kind"] == "workload_shape"
272
+ refs = Array(data["source_refs"]) + Array(data["behavior_refs"])
273
+ refs.uniq.each do |ref|
274
+ expected = ref.to_s.start_with?("BEH-") ? %w[BEH] : %w[REQ BEH API]
275
+ errors << "#{node["id"]}: workload source #{ref} must exist in the graph" unless refs_known?(graph, [ref], expected)
276
+ errors << "#{node["id"]}: workload source #{ref} must link with shapes_workload" if require_edges && !incoming_edge?(graph, ref, node["id"], "shapes_workload")
277
+ errors << "#{node["id"]}: workload source #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
278
+ end
279
+ else
280
+ workload_ref = data["workload_ref"]
281
+ errors << "#{node["id"]}: workload_ref #{workload_ref} must exist in the graph" unless refs_known?(graph, [workload_ref], %w[BEH SL API])
282
+ errors << "#{node["id"]}: workload_ref #{workload_ref} must link with constrains_workload" if require_edges && !incoming_edge?(graph, workload_ref, node["id"], "constrains_workload")
283
+ errors << "#{node["id"]}: workload_ref #{workload_ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, workload_ref)
284
+
285
+ evidence = data["evidence"]
286
+ if evidence.is_a?(Hash) && evidence["kind"] == "source_file"
287
+ evidence_ref = evidence["ref"]
288
+ errors << "#{node["id"]}: source_file evidence.ref must reference SRC/CODE" unless refs_known?(graph, [evidence_ref], %w[SRC CODE])
289
+ errors << "#{node["id"]}: source_file evidence.ref must be confirmed" if require_confirmed_inputs && !confirmed?(graph, evidence_ref)
290
+ end
291
+ end
292
+ errors.uniq
293
+ end
294
+
295
+ def workload_confirmation_errors(graph, node, evidence)
296
+ errors = workload_node_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
297
+ if node["kind"] == "workload_constraint" && %w[approved_assumption unknown].include?(node.dig("data", "source_status")) && !evidence["cp_record"]
298
+ errors << "#{node["id"]}: #{node.dig("data", "source_status")} confirmation requires cp_record"
299
+ end
300
+ errors
301
+ end
302
+
303
+ def architecture_option_decision?(node)
304
+ node["kind"] == "decision" && node.dig("data", "decision_type") == "architecture_option_comparison"
305
+ end
306
+
307
+ # Keep architecture comparison inside the existing DECISION vocabulary. The
308
+ # typed payload prevents a prose-only "A/B" list from masquerading as a
309
+ # decision: every option needs a mechanism, fact-bound evidence and tradeoffs.
310
+ def architecture_decision_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
311
+ return [] unless architecture_option_decision?(node)
312
+
313
+ data = node.fetch("data", {})
314
+ errors = []
315
+ subject_refs = data["subject_refs"]
316
+ errors << "#{node["id"]}: architecture decision subject_refs must be a non-empty array" unless subject_refs.is_a?(Array) && !subject_refs.empty?
317
+ Array(subject_refs).each do |ref|
318
+ errors << "#{node["id"]}: architecture decision subject_ref #{ref} must reference WSH/WLC" unless refs_known?(graph, [ref], %w[WSH WLC])
319
+ relation = ref.to_s.start_with?("WSH-") ? "informs_decision" : "constrains_decision"
320
+ errors << "#{node["id"]}: architecture decision subject_ref #{ref} must link with #{relation}" if require_edges && !incoming_edge?(graph, ref, node["id"], relation)
321
+ errors << "#{node["id"]}: architecture decision subject_ref #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
322
+ end
323
+
324
+ comparison = data["comparison"]
325
+ unless comparison.is_a?(Hash)
326
+ errors << "#{node["id"]}: architecture decision comparison is required"
327
+ return errors.uniq
328
+ end
329
+ mode = comparison["mode"]
330
+ errors << "#{node["id"]}: architecture decision comparison.mode is invalid" unless %w[multiple_options single_path].include?(mode)
331
+ options = comparison["options"]
332
+ errors << "#{node["id"]}: architecture decision comparison.options must be an array" unless options.is_a?(Array)
333
+ options = Array(options)
334
+ errors << "#{node["id"]}: multiple_options requires at least two materially different options" if mode == "multiple_options" && options.length < 2
335
+ errors << "#{node["id"]}: single_path requires exactly one option" if mode == "single_path" && options.length != 1
336
+ option_ids = []
337
+ options.each do |option|
338
+ unless option.is_a?(Hash)
339
+ errors << "#{node["id"]}: comparison option must be an object"
340
+ next
341
+ end
342
+ option_key = option["option_key"].to_s
343
+ option_ids << option_key unless option_key.empty?
344
+ label = option_key.empty? ? "<missing>" : option_key
345
+ errors << "#{node["id"]}: comparison option option_key is required" if option_key.empty?
346
+ errors << "#{node["id"]}: option #{label} requires mechanism" unless present?(option["mechanism"])
347
+ errors << "#{node["id"]}: option #{label} requires non-empty tradeoffs" unless option["tradeoffs"].is_a?(Array) && !option["tradeoffs"].empty?
348
+ refs = option["fact_refs"]
349
+ errors << "#{node["id"]}: option #{label} requires fact_refs" unless refs.is_a?(Array) && !refs.empty?
350
+ Array(refs).each do |ref|
351
+ errors << "#{node["id"]}: option #{label} fact_ref #{ref} must reference WSH/WLC" unless refs_known?(graph, [ref], %w[WSH WLC])
352
+ end
353
+ end
354
+ errors << "#{node["id"]}: architecture decision option ids must be unique" unless option_ids.uniq.length == option_ids.length
355
+ recommendation = comparison["recommendation_option_id"]
356
+ errors << "#{node["id"]}: architecture decision recommendation_option_id must name a listed option" unless option_ids.include?(recommendation)
357
+ errors.uniq
358
+ end
359
+
360
+ def architecture_decision_confirmation_errors(graph, node)
361
+ architecture_decision_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
362
+ end
363
+
364
+ def architecture_option_risk?(node)
365
+ node["kind"] == "risk" && node.dig("data", "risk_type") == "architecture_option_risk"
366
+ end
367
+
368
+ def architecture_risk_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
369
+ return [] unless architecture_option_risk?(node)
370
+
371
+ data = node.fetch("data", {})
372
+ errors = []
373
+ refs = data["subject_refs"]
374
+ errors << "#{node["id"]}: architecture risk subject_refs must be a non-empty array" unless refs.is_a?(Array) && !refs.empty?
375
+ Array(refs).each do |ref|
376
+ errors << "#{node["id"]}: architecture risk subject_ref #{ref} must reference DECISION/WSH/WLC" unless refs_known?(graph, [ref], %w[DECISION WSH WLC])
377
+ errors << "#{node["id"]}: architecture risk subject_ref #{ref} must link with has_architecture_risk" if require_edges && !incoming_edge?(graph, ref, node["id"], "has_architecture_risk")
378
+ errors << "#{node["id"]}: architecture risk subject_ref #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
379
+ end
380
+ %w[failure_mode impact mitigation owner review_condition].each do |field|
381
+ errors << "#{node["id"]}: architecture risk requires #{field}" unless present?(data[field])
382
+ end
383
+ errors << "#{node["id"]}: architecture risk treatment is invalid" unless %w[mitigate accept].include?(data["treatment"])
384
+ errors.uniq
385
+ end
386
+
387
+ def architecture_risk_confirmation_errors(graph, node)
388
+ architecture_risk_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
389
+ end
390
+
391
+ def architecture_control_node?(node)
392
+ architecture_option_decision?(node) || architecture_option_risk?(node)
393
+ end
394
+
395
+ def confirmed?(graph, id)
396
+ node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
397
+ !node.nil? && node["status"] == "confirmed"
398
+ end
399
+
400
+ def checker_results(graph)
401
+ results = {
402
+ "requirement-graph-check" => [], "slice-independence-check" => [], "contract-coverage-check" => [],
403
+ "change-impact-check" => [], "acceptance-coverage-check" => [], "risk-derivation-check" => []
404
+ }
405
+ Array(graph["nodes"]).each do |node|
406
+ next unless node.is_a?(Hash)
407
+ case node["kind"]
408
+ when "requirement"
409
+ required(results["requirement-graph-check"], node, %w[source_refs outcome actor_and_authority data_scope behavior_ref failure_refs acceptance_refs])
410
+ results["requirement-graph-check"] << "#{node["id"]}: source_refs must reference SRC/CODE nodes" unless refs_known?(graph, node.dig("data", "source_refs"), %w[SRC CODE])
411
+ results["requirement-graph-check"] << "#{node["id"]}: behavior_ref must reference BEH" unless refs_known?(graph, [node.dig("data", "behavior_ref")], ["BEH"])
412
+ results["requirement-graph-check"] << "#{node["id"]}: acceptance_refs must reference AC" unless refs_known?(graph, node.dig("data", "acceptance_refs"), ["AC"])
413
+ if node["status"] == "confirmed"
414
+ Array(node.dig("data", "source_refs")).each do |ref|
415
+ results["requirement-graph-check"] << "#{node["id"]}: source_ref #{ref} must be confirmed before backing a confirmed requirement" unless confirmed?(graph, ref)
416
+ end
417
+ beh = node.dig("data", "behavior_ref")
418
+ results["requirement-graph-check"] << "#{node["id"]}: behavior_ref #{beh} must be confirmed" if beh && !confirmed?(graph, beh)
419
+ end
420
+ when "behavior"
421
+ required(results["requirement-graph-check"], node, %w[trigger actor_and_authority preconditions state_transition invariants writes side_effects failure_paths recovery_strategy])
422
+ when "slice"
423
+ required(results["slice-independence-check"], node, %w[outcome state_change actor_and_authority data_scope reads writes side_effects dependencies acceptance_refs])
424
+ results["slice-independence-check"] << "#{node["id"]}: technical_only slice is not an independently verifiable business result" if node.dig("data", "technical_only") == true
425
+ when "api"
426
+ required(results["contract-coverage-check"], node, %w[request response errors permission data_scope compatibility behavior_ref requirement_refs])
427
+ results["contract-coverage-check"] << "#{node["id"]}: behavior_ref must reference BEH" unless refs_known?(graph, [node.dig("data", "behavior_ref")], ["BEH"])
428
+ results["contract-coverage-check"] << "#{node["id"]}: requirement_refs must reference REQ" unless refs_known?(graph, node.dig("data", "requirement_refs"), ["REQ"])
429
+ when "decision"
430
+ results["requirement-graph-check"].concat(
431
+ architecture_decision_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
432
+ )
433
+ when "risk"
434
+ results["requirement-graph-check"].concat(
435
+ architecture_risk_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
436
+ )
437
+ when "workload_shape", "workload_constraint"
438
+ results["requirement-graph-check"].concat(
439
+ workload_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
440
+ )
441
+ when "acceptance"
442
+ required(results["acceptance-coverage-check"], node, %w[requirement_ref verification_types])
443
+ types = Array(node.dig("data", "verification_types"))
444
+ req = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node.dig("data", "requirement_ref") }
445
+ if req && high_risk_by_structure?(req)
446
+ missing = %w[business contract resilience] - types
447
+ results["acceptance-coverage-check"] << "#{node["id"]}: high-risk acceptance missing #{missing.join(", ")}" if missing.any?
448
+ end
449
+ end
450
+ end
451
+ stale = Array(graph["stale"])
452
+ results["change-impact-check"] << "#{stale.length} downstream nodes need recheck" if stale.any?
453
+ Array(graph["nodes"]).each do |node|
454
+ next unless node.is_a?(Hash)
455
+ data = node["data"].is_a?(Hash) ? node["data"] : {}
456
+ %w[severity risk_level priority p0 high_risk].each do |key|
457
+ results["risk-derivation-check"] << "#{node["id"]}: #{key} must be Core-derived, not model-authored" if data.key?(key)
458
+ end
459
+ end
460
+ derived = derived_high_risk_ids(graph)
461
+ results["risk-derivation-check"] << "derived high-risk nodes: #{derived.join(", ")}" unless derived.empty?
462
+ results
463
+ end
464
+
465
+ def validate(graph)
466
+ errors = []
467
+ ids = []
468
+ Array(graph["nodes"]).each do |node|
469
+ unless node.is_a?(Hash) && node["id"].is_a?(String) && KINDS.key?(node["kind"]) && %w[candidate confirmed].include?(node["status"]) && node["data"].is_a?(Hash)
470
+ errors << "invalid node"
471
+ next
472
+ end
473
+ ids << node["id"]
474
+ end
475
+ errors << "duplicate node ids" unless ids.uniq.length == ids.length
476
+ known = ids.to_set
477
+ Array(graph["edges"]).each do |edge|
478
+ errors << "invalid edge" unless edge.is_a?(Hash) && known.include?(edge["from"]) && known.include?(edge["to"]) && edge["relation"].is_a?(String)
479
+ end
480
+ checker_results(graph).each_value { |issues| errors.concat(issues) }
481
+ Array(graph["nodes"]).each do |node|
482
+ next unless node.is_a?(Hash) && high_risk_by_structure?(node)
483
+ errors << "#{node["id"]}: derived high-risk node must be confirmed and provable" unless node["status"] == "confirmed"
484
+ end
485
+ errors.uniq
486
+ end
487
+
488
+ def view_payload(graph, name)
489
+ kinds = case name
490
+ when "evidence" then %w[source code]
491
+ when "definitions" then %w[term object rule boundary]
492
+ when "behavior-graph" then %w[behavior]
493
+ when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint]
494
+ when "slices" then %w[slice]
495
+ when "acceptance" then %w[acceptance risk decision]
496
+ else []
497
+ end
498
+ nodes = Array(graph["nodes"]).select { |node| kinds.include?(node["kind"]) }
499
+ { "schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
500
+ "view" => name, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| nodes.any? { |node| node["id"] == edge["from"] || node["id"] == edge["to"] } } }
501
+ end
502
+
503
+ def write_views!(root, graph, stale: false)
504
+ directory = feature_dir(root, graph["feature"])
505
+ graph["views"] ||= {}
506
+ VIEW_NAMES.each do |name|
507
+ payload = view_payload(graph, name)
508
+ path = File.join(directory, "#{name}.json")
509
+ prior = graph["views"][name] || {}
510
+ state = stale ? "stale" : "fresh"
511
+ meta = { "path" => File.join(ROOT, graph["feature"], "#{name}.json"), "fingerprint" => fingerprint(payload),
512
+ "graph_revision" => graph["revision"], "state" => state, "needs_recheck" => stale,
513
+ "stale_fingerprint" => stale ? prior["fingerprint"] : nil }.compact
514
+ graph["views"][name] = meta
515
+ payload["view_meta"] = meta
516
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(payload) + "\n")
517
+ end
518
+ meta = { "schema_version" => SCHEMA_VERSION, "feature" => graph["feature"], "graph_revision" => graph["revision"],
519
+ "content_fingerprint" => fingerprint(graph.reject { |key, _| %w[views audit].include?(key) }), "views" => graph["views"], "stale" => graph["stale"] }
520
+ DevflowControlIO.atomic_write(File.join(directory, "graph.meta.json"), JSON.pretty_generate(meta) + "\n")
521
+ end
522
+
523
+ def append_audit!(root, graph, event)
524
+ event = event.merge("schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "graph_revision" => graph["revision"])
525
+ graph["audit"] << event
526
+ graph["audit"] = graph["audit"].last(200)
527
+ path = File.join(feature_dir(root, graph["feature"]), "control-events.jsonl")
528
+ existing = File.exist?(path) ? File.read(path) : ""
529
+ DevflowControlIO.atomic_write(path, existing + JSON.generate(event) + "\n")
530
+ end
531
+
532
+ def context(graph, slice_id)
533
+ raise ArgumentError, "slice_id is required" unless slice_id.is_a?(String) && slice_id.match?(/\ASL-\d+\z/)
534
+ ids = Set.new([slice_id])
535
+ Array(graph["edges"]).each do |edge|
536
+ next unless edge.is_a?(Hash)
537
+ ids << edge["to"] if edge["from"] == slice_id
538
+ ids << edge["from"] if edge["to"] == slice_id
539
+ end
540
+ nodes = Array(graph["nodes"]).select { |node| ids.include?(node["id"]) }
541
+ payload = { "schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
542
+ "slice_id" => slice_id, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| ids.include?(edge["from"]) && ids.include?(edge["to"]) } }
543
+ payload["fingerprint"] = fingerprint(payload)
544
+ payload
545
+ end
546
+
547
+ # 消费者读已持久化 L2 包并强制校验 revision/fingerprint。
548
+ def load_context_package(root, feature, slice_id)
549
+ directory = File.join(feature_dir(root, feature), "context")
550
+ path = File.join(directory, "#{slice_id}.json")
551
+ return nil unless File.exist?(path)
552
+ JSON.parse(File.read(path))
553
+ rescue JSON::ParserError
554
+ nil
555
+ end
556
+ end
557
+
558
+ if __FILE__ == $PROGRAM_NAME
559
+ options = { feature: nil, expected_revision: nil, delta_json: nil, slice_id: nil, node_id: nil, evidence_json: nil, allowed_kinds: nil, confirmed_interactively: false, write: false, read: false, json: false }
560
+ command = ARGV.shift
561
+ OptionParser.new do |opts|
562
+ opts.banner = "Usage: requirement_graph.rb propose|inspect|validate|context --feature NAME [options]"
563
+ opts.on("--feature NAME") { |value| options[:feature] = value }
564
+ opts.on("--expected-revision N", Integer) { |value| options[:expected_revision] = value }
565
+ opts.on("--delta-json JSON") { |value| options[:delta_json] = value }
566
+ opts.on("--slice-id ID") { |value| options[:slice_id] = value }
567
+ opts.on("--node-id ID") { |value| options[:node_id] = value }
568
+ opts.on("--evidence-json JSON") { |value| options[:evidence_json] = value }
569
+ opts.on("--allowed-kinds LIST") { |value| options[:allowed_kinds] = value }
570
+ opts.on("--confirmed-interactively") { options[:confirmed_interactively] = true }
571
+ opts.on("--write") { options[:write] = true }
572
+ opts.on("--read") { options[:read] = true }
573
+ opts.on("--json") { options[:json] = true }
574
+ end.parse!
575
+ root = Dir.pwd
576
+ lock = %w[propose context confirm].include?(command) && options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
577
+ begin
578
+ graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
579
+ result = case command
580
+ when "inspect"
581
+ { "ok" => true, "path" => relative, "graph" => graph, "errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph) }
582
+ when "validate"
583
+ errors = RequirementGraph.validate(graph)
584
+ { "ok" => errors.empty?, "path" => relative, "revision" => graph["revision"], "errors" => errors, "checkers" => RequirementGraph.checker_results(graph), "stale" => graph["stale"] }
585
+ when "context"
586
+ if options[:read]
587
+ pkg = RequirementGraph.load_context_package(root, graph["feature"], options[:slice_id])
588
+ if pkg.nil?
589
+ { "ok" => false, "reason" => "context_package_missing", "slice_id" => options[:slice_id] }
590
+ elsif pkg["graph_revision"] != graph["revision"] || pkg["fingerprint"].nil?
591
+ { "ok" => false, "reason" => "stale_context_package", "slice_id" => options[:slice_id], "package_revision" => pkg["graph_revision"], "graph_revision" => graph["revision"] }
592
+ else
593
+ { "ok" => true, "path" => relative, "context" => pkg, "fresh" => true }
594
+ end
595
+ else
596
+ ctx = RequirementGraph.context(graph, options[:slice_id])
597
+ if options[:write]
598
+ destination = File.join(RequirementGraph.feature_dir(root, graph["feature"]), "context", "#{options[:slice_id]}.json")
599
+ DevflowControlIO.atomic_write(destination, JSON.pretty_generate(ctx) + "\n")
600
+ ctx["path"] = File.join(RequirementGraph::ROOT, graph["feature"], "context", "#{options[:slice_id]}.json")
601
+ end
602
+ { "ok" => true, "path" => relative, "context" => ctx }
603
+ end
604
+ when "propose"
605
+ raise ArgumentError, "--write is required" unless options[:write]
606
+ raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
607
+ if graph["revision"] != options[:expected_revision]
608
+ { "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
609
+ else
610
+ delta = JSON.parse(options[:delta_json].to_s)
611
+ allowed_kinds = options[:allowed_kinds]&.split(",")&.map(&:strip)
612
+ applied = RequirementGraph.proposal(graph, delta, allowed_kinds)
613
+ graph["revision"] += 1
614
+ stale = Array(graph["stale"]).any?
615
+ RequirementGraph.write_views!(root, graph, stale: stale)
616
+ RequirementGraph.append_audit!(root, graph, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
617
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
618
+ { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
619
+ end
620
+ when "confirm"
621
+ raise ArgumentError, "--write is required" unless options[:write]
622
+ raise ArgumentError, "--node-id is required" unless options[:node_id]
623
+ raise ArgumentError, "--evidence-json is required" unless options[:evidence_json]
624
+ raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
625
+ if graph["revision"] != options[:expected_revision]
626
+ { "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
627
+ else
628
+ evidence = JSON.parse(options[:evidence_json].to_s)
629
+ applied = RequirementGraph.confirm(graph, options[:node_id], evidence, confirmed_interactively: options[:confirmed_interactively])
630
+ graph["revision"] += 1
631
+ stale = Array(graph["stale"]).any?
632
+ RequirementGraph.write_views!(root, graph, stale: stale)
633
+ RequirementGraph.append_audit!(root, graph, { "event" => "graph_confirm", "node_id" => options[:node_id] })
634
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
635
+ { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
636
+ end
637
+ else
638
+ { "ok" => false, "reason" => "invalid_command", "message" => "command must be propose, inspect, validate, context, or confirm" }
639
+ end
640
+ rescue ArgumentError, JSON::ParserError => error
641
+ result = { "ok" => false, "reason" => "invalid_requirement_graph_input", "message" => error.message }
642
+ ensure
643
+ DevflowControlIO.release_lock(lock)
644
+ end
645
+ puts JSON.pretty_generate(result)
646
+ end