@guanlin4924/devflow-pi 0.1.14 → 0.1.16

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 (22) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +179 -54
  3. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +4 -4
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +93 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +2 -1
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +111 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +471 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/traceability_check.py +160 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +85 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +59 -3
  11. package/plugins/devflow-core/skills/workflow-orchestrator/templates/traceability.json +23 -0
  12. package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +4 -1
  13. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +4 -0
  14. package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +4 -0
  15. package/plugins/devflow-design/skills/workflow-integration-verify/SKILL.md +10 -0
  16. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +8 -0
  17. package/plugins/devflow-requirements/skills/requirement-feasibility-review/SKILL.md +4 -0
  18. package/plugins/devflow-requirements/skills/workflow-domain-modeling/SKILL.md +4 -0
  19. package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +13 -0
  20. package/plugins/devflow-requirements/skills/workflow-requirement-triage/SKILL.md +4 -0
  21. package/src/cli.ts +2 -1
  22. package/src/runtime.ts +2 -1
@@ -0,0 +1,471 @@
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
+
14
+ module RequirementGraph
15
+ SCHEMA_VERSION = 2
16
+ ROOT = ".planning/requirements"
17
+ KINDS = {
18
+ "source" => "SRC", "code" => "CODE", "term" => "TERM", "object" => "OBJECT",
19
+ "behavior" => "BEH", "requirement" => "REQ", "rule" => "BR", "boundary" => "BC",
20
+ "slice" => "SL", "risk" => "RISK", "decision" => "DECISION", "acceptance" => "AC",
21
+ "api" => "API"
22
+ }.freeze
23
+ VIEW_NAMES = %w[evidence definitions behavior-graph requirement-graph slices acceptance traceability].freeze
24
+ FEATURE_INVALID = /[\\\/\x00-\x1F\x7F]/
25
+ RESERVED_KEYS = %w[id status revision passed confirmed graph_revision stale_fingerprint severity risk_level priority p0 high_risk].freeze
26
+
27
+ module_function
28
+
29
+ def valid_feature?(value)
30
+ value.is_a?(String) && !value.strip.empty? && !value.match?(FEATURE_INVALID)
31
+ end
32
+
33
+ def graph_path(root, feature)
34
+ raise ArgumentError, "invalid feature" unless valid_feature?(feature)
35
+ relative = File.join(ROOT, feature, "governance.json")
36
+ absolute = File.expand_path(relative, root)
37
+ root_real = File.realpath(root)
38
+ raise ArgumentError, "graph path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
39
+ [absolute, relative]
40
+ end
41
+
42
+ def feature_dir(root, feature)
43
+ path, = graph_path(root, feature)
44
+ File.dirname(path)
45
+ end
46
+
47
+ def empty_graph(feature)
48
+ { "schema_version" => SCHEMA_VERSION, "feature" => feature, "revision" => 0,
49
+ "nodes" => [], "edges" => [], "stale" => [], "views" => {}, "audit" => [] }
50
+ end
51
+
52
+ def canonical_json(value)
53
+ JSON.generate(value)
54
+ end
55
+
56
+ def fingerprint(value)
57
+ Digest::SHA256.hexdigest(canonical_json(value))
58
+ end
59
+
60
+ def normalize_legacy_graph(graph)
61
+ graph["schema_version"] = SCHEMA_VERSION
62
+ graph["views"] ||= {}
63
+ graph["audit"] ||= []
64
+ graph["stale"] = Array(graph["stale"]).map do |entry|
65
+ entry.is_a?(Hash) ? entry : { "node_id" => entry.to_s, "state" => "needs_recheck", "reason" => "legacy_unknown" }
66
+ end
67
+ Array(graph["nodes"]).each { |node| node["fingerprint"] ||= fingerprint(node.reject { |key, _| key == "fingerprint" }) if node.is_a?(Hash) }
68
+ graph
69
+ end
70
+
71
+ def load_graph(root, feature)
72
+ path, relative = graph_path(root, feature)
73
+ return [empty_graph(feature), path, relative] unless File.exist?(path)
74
+ parsed = JSON.parse(File.read(path))
75
+ valid_shape = parsed.is_a?(Hash) && [1, SCHEMA_VERSION].include?(parsed["schema_version"]) && parsed["feature"] == feature &&
76
+ parsed["revision"].is_a?(Integer) && parsed["nodes"].is_a?(Array) && parsed["edges"].is_a?(Array) && parsed["stale"].is_a?(Array)
77
+ raise ArgumentError, "invalid governance graph" unless valid_shape
78
+ [normalize_legacy_graph(parsed), path, relative]
79
+ rescue JSON::ParserError
80
+ raise ArgumentError, "invalid governance graph"
81
+ end
82
+
83
+ def contains_reserved_key?(value)
84
+ case value
85
+ when Hash
86
+ value.any? { |key, child| RESERVED_KEYS.include?(key.to_s) || contains_reserved_key?(child) }
87
+ when Array then value.any? { |child| contains_reserved_key?(child) }
88
+ else false
89
+ end
90
+ end
91
+
92
+ def normalize_data(value)
93
+ raise ArgumentError, "delta data must be an object" unless value.is_a?(Hash)
94
+ raise ArgumentError, "delta data cannot set reserved keys: #{RESERVED_KEYS.join(", ")}" if contains_reserved_key?(value)
95
+ value
96
+ end
97
+
98
+ def next_id(graph, prefix)
99
+ maximum = graph["nodes"].map do |node|
100
+ match = node.is_a?(Hash) && node["id"].is_a?(String) ? node["id"].match(/\A#{Regexp.escape(prefix)}-(\d+)\z/) : nil
101
+ match && match[1].to_i
102
+ end.compact.max || 0
103
+ format("%s-%04d", prefix, maximum + 1)
104
+ end
105
+
106
+ def node_ids(graph)
107
+ graph["nodes"].map { |node| node["id"] if node.is_a?(Hash) && node["id"].is_a?(String) }.compact.to_set
108
+ end
109
+
110
+ def set_node_fingerprint!(node)
111
+ node["fingerprint"] = fingerprint(node.reject { |key, _| key == "fingerprint" })
112
+ end
113
+
114
+ def downstream(graph, id, mode)
115
+ direct = Array(graph["edges"]).map { |edge| edge["to"] if edge.is_a?(Hash) && edge["from"] == id }.compact
116
+ return direct.uniq if mode == "one_hop"
117
+ seen = Set.new([id])
118
+ queue = direct.dup
119
+ result = []
120
+ until queue.empty?
121
+ current = queue.shift
122
+ next if seen.include?(current)
123
+ seen << current
124
+ result << current
125
+ Array(graph["edges"]).each { |edge| queue << edge["to"] if edge.is_a?(Hash) && edge["from"] == current }
126
+ end
127
+ result
128
+ end
129
+
130
+ def mark_stale!(graph, upstream, affected)
131
+ affected.each do |node_id|
132
+ node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node_id }
133
+ graph["stale"] << { "node_id" => node_id, "state" => "needs_recheck", "reason" => "upstream_changed",
134
+ "upstream" => upstream, "stale_fingerprint" => node && node["fingerprint"] }
135
+ end
136
+ graph["stale"].uniq! { |entry| [entry["node_id"], entry["upstream"], entry["stale_fingerprint"]] }
137
+ graph["stale"].each { |entry| entry["graph_revision"] = graph["revision"] + 1 }
138
+ end
139
+
140
+ # Re-submitting a changed downstream node is the explicit Core-mediated
141
+ # acknowledgement that its stale fingerprint was reviewed. It may itself
142
+ # invalidate later consumers, so only that node's stale marker is cleared.
143
+ def clear_stale_for!(graph, node_id)
144
+ graph["stale"].reject! { |entry| entry.is_a?(Hash) && entry["node_id"] == node_id }
145
+ end
146
+
147
+ def proposal(graph, delta, allowed_kinds = nil)
148
+ raise ArgumentError, "delta must be an object" unless delta.is_a?(Hash)
149
+ case delta["op"]
150
+ when "add"
151
+ kind = delta["kind"]
152
+ prefix = KINDS[kind]
153
+ raise ArgumentError, "unsupported node kind" unless prefix
154
+ raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
155
+ data = normalize_data(delta["data"])
156
+ node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
157
+ set_node_fingerprint!(node)
158
+ graph["nodes"] << node
159
+ { "operation" => "add", "node" => node }
160
+ when "link"
161
+ from, to, relation = delta.values_at("from", "to", "relation")
162
+ 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)
163
+ raise ArgumentError, "link relation is required" unless relation.is_a?(String) && relation.match?(/\A[a-z][a-z0-9_-]{0,63}\z/)
164
+ edge = { "from" => from, "to" => to, "relation" => relation }
165
+ raise ArgumentError, "duplicate graph edge" if graph["edges"].include?(edge)
166
+ graph["edges"] << edge
167
+ { "operation" => "link", "edge" => edge }
168
+ when "update"
169
+ id = delta["id"]
170
+ node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
171
+ raise ArgumentError, "update requires a known node id" unless node
172
+ data = normalize_data(delta["data"])
173
+ node["data"] = node.fetch("data", {}).merge(data)
174
+ set_node_fingerprint!(node)
175
+ clear_stale_for!(graph, id)
176
+ mode = delta["propagation"] || "full"
177
+ raise ArgumentError, "propagation must be one_hop or full" unless %w[one_hop full].include?(mode)
178
+ affected = downstream(graph, id, mode)
179
+ mark_stale!(graph, id, affected)
180
+ { "operation" => "update", "node" => node, "stale" => affected, "propagation" => mode }
181
+ else
182
+ raise ArgumentError, "delta op must be add, link, or update"
183
+ end
184
+ end
185
+
186
+ # Core-only 审核转换:candidate → confirmed,要求人工 CP 记录或运行证据。
187
+ def confirm(graph, node_id, evidence)
188
+ node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node_id }
189
+ raise ArgumentError, "confirm requires a known node id" unless node
190
+ raise ArgumentError, "node is already confirmed" if node["status"] == "confirmed"
191
+ unless evidence.is_a?(Hash) && (evidence["cp_record"] || evidence["evidence_ref"])
192
+ raise ArgumentError, "confirm requires cp_record or evidence_ref"
193
+ end
194
+ node["status"] = "confirmed"
195
+ node["confirmation"] = { "origin" => "core", "evidence" => evidence }
196
+ set_node_fingerprint!(node)
197
+ clear_stale_for!(graph, node_id)
198
+ { "operation" => "confirm", "node" => node }
199
+ end
200
+
201
+ def present?(value)
202
+ !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
203
+ end
204
+
205
+ # 风险等级不是模型可填写的枚举,而由 Core 从图谱结构事实推导。
206
+ def high_risk_by_structure?(node)
207
+ data = node["data"].is_a?(Hash) ? node["data"] : {}
208
+ if node["kind"] == "behavior"
209
+ has_writes = present?(data["writes"]) || present?(data["side_effects"])
210
+ return true if has_writes && !present?(data["recovery_strategy"])
211
+ return true if data["concurrent"] == true && !present?(data["concurrency_control"])
212
+ end
213
+ if %w[requirement slice].include?(node["kind"])
214
+ scope = data["data_scope"]
215
+ return true if scope.is_a?(String) && !%w[current_org_only self self_only].include?(scope)
216
+ end
217
+ false
218
+ end
219
+
220
+ def derived_high_risk_ids(graph)
221
+ Array(graph["nodes"]).select { |node| node.is_a?(Hash) && high_risk_by_structure?(node) }.map { |node| node["id"] }
222
+ end
223
+
224
+ def required(errors, node, keys)
225
+ keys.each { |key| errors << "#{node["id"]}: missing #{key}" unless present?(node.dig("data", key)) }
226
+ end
227
+
228
+ def refs_known?(graph, refs, prefixes)
229
+ known = node_ids(graph)
230
+ Array(refs).all? { |ref| ref.is_a?(String) && known.include?(ref) && prefixes.any? { |prefix| ref.start_with?("#{prefix}-") } }
231
+ end
232
+
233
+ def confirmed?(graph, id)
234
+ node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
235
+ !node.nil? && node["status"] == "confirmed"
236
+ end
237
+
238
+ def checker_results(graph)
239
+ results = {
240
+ "requirement-graph-check" => [], "slice-independence-check" => [], "contract-coverage-check" => [],
241
+ "change-impact-check" => [], "acceptance-coverage-check" => [], "risk-derivation-check" => []
242
+ }
243
+ Array(graph["nodes"]).each do |node|
244
+ next unless node.is_a?(Hash)
245
+ case node["kind"]
246
+ when "requirement"
247
+ required(results["requirement-graph-check"], node, %w[source_refs outcome actor_and_authority data_scope behavior_ref failure_refs acceptance_refs])
248
+ 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])
249
+ results["requirement-graph-check"] << "#{node["id"]}: behavior_ref must reference BEH" unless refs_known?(graph, [node.dig("data", "behavior_ref")], ["BEH"])
250
+ results["requirement-graph-check"] << "#{node["id"]}: acceptance_refs must reference AC" unless refs_known?(graph, node.dig("data", "acceptance_refs"), ["AC"])
251
+ if node["status"] == "confirmed"
252
+ Array(node.dig("data", "source_refs")).each do |ref|
253
+ results["requirement-graph-check"] << "#{node["id"]}: source_ref #{ref} must be confirmed before backing a confirmed requirement" unless confirmed?(graph, ref)
254
+ end
255
+ beh = node.dig("data", "behavior_ref")
256
+ results["requirement-graph-check"] << "#{node["id"]}: behavior_ref #{beh} must be confirmed" if beh && !confirmed?(graph, beh)
257
+ end
258
+ when "behavior"
259
+ required(results["requirement-graph-check"], node, %w[trigger actor_and_authority preconditions state_transition invariants writes side_effects failure_paths recovery_strategy])
260
+ when "slice"
261
+ required(results["slice-independence-check"], node, %w[outcome state_change actor_and_authority data_scope reads writes side_effects dependencies acceptance_refs])
262
+ results["slice-independence-check"] << "#{node["id"]}: technical_only slice is not an independently verifiable business result" if node.dig("data", "technical_only") == true
263
+ when "api"
264
+ required(results["contract-coverage-check"], node, %w[request response errors permission data_scope compatibility behavior_ref requirement_refs])
265
+ results["contract-coverage-check"] << "#{node["id"]}: behavior_ref must reference BEH" unless refs_known?(graph, [node.dig("data", "behavior_ref")], ["BEH"])
266
+ results["contract-coverage-check"] << "#{node["id"]}: requirement_refs must reference REQ" unless refs_known?(graph, node.dig("data", "requirement_refs"), ["REQ"])
267
+ when "acceptance"
268
+ required(results["acceptance-coverage-check"], node, %w[requirement_ref verification_types])
269
+ types = Array(node.dig("data", "verification_types"))
270
+ req = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node.dig("data", "requirement_ref") }
271
+ if req && high_risk_by_structure?(req)
272
+ missing = %w[business contract resilience] - types
273
+ results["acceptance-coverage-check"] << "#{node["id"]}: high-risk acceptance missing #{missing.join(", ")}" if missing.any?
274
+ end
275
+ end
276
+ end
277
+ stale = Array(graph["stale"])
278
+ results["change-impact-check"] << "#{stale.length} downstream nodes need recheck" if stale.any?
279
+ Array(graph["nodes"]).each do |node|
280
+ next unless node.is_a?(Hash)
281
+ data = node["data"].is_a?(Hash) ? node["data"] : {}
282
+ %w[severity risk_level priority p0 high_risk].each do |key|
283
+ results["risk-derivation-check"] << "#{node["id"]}: #{key} must be Core-derived, not model-authored" if data.key?(key)
284
+ end
285
+ end
286
+ derived = derived_high_risk_ids(graph)
287
+ results["risk-derivation-check"] << "derived high-risk nodes: #{derived.join(", ")}" unless derived.empty?
288
+ results
289
+ end
290
+
291
+ def validate(graph)
292
+ errors = []
293
+ ids = []
294
+ Array(graph["nodes"]).each do |node|
295
+ 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)
296
+ errors << "invalid node"
297
+ next
298
+ end
299
+ ids << node["id"]
300
+ end
301
+ errors << "duplicate node ids" unless ids.uniq.length == ids.length
302
+ known = ids.to_set
303
+ Array(graph["edges"]).each do |edge|
304
+ errors << "invalid edge" unless edge.is_a?(Hash) && known.include?(edge["from"]) && known.include?(edge["to"]) && edge["relation"].is_a?(String)
305
+ end
306
+ checker_results(graph).each_value { |issues| errors.concat(issues) }
307
+ Array(graph["nodes"]).each do |node|
308
+ next unless node.is_a?(Hash) && high_risk_by_structure?(node)
309
+ errors << "#{node["id"]}: derived high-risk node must be confirmed and provable" unless node["status"] == "confirmed"
310
+ end
311
+ errors.uniq
312
+ end
313
+
314
+ def view_payload(graph, name)
315
+ kinds = case name
316
+ when "evidence" then %w[source code]
317
+ when "definitions" then %w[term object rule boundary]
318
+ when "behavior-graph" then %w[behavior]
319
+ when "requirement-graph", "traceability" then %w[requirement rule boundary api]
320
+ when "slices" then %w[slice]
321
+ when "acceptance" then %w[acceptance risk decision]
322
+ else []
323
+ end
324
+ nodes = Array(graph["nodes"]).select { |node| kinds.include?(node["kind"]) }
325
+ { "schema_version" => 2, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
326
+ "view" => name, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| nodes.any? { |node| node["id"] == edge["from"] || node["id"] == edge["to"] } } }
327
+ end
328
+
329
+ def write_views!(root, graph, stale: false)
330
+ directory = feature_dir(root, graph["feature"])
331
+ graph["views"] ||= {}
332
+ VIEW_NAMES.each do |name|
333
+ payload = view_payload(graph, name)
334
+ path = File.join(directory, "#{name}.json")
335
+ prior = graph["views"][name] || {}
336
+ state = stale ? "stale" : "fresh"
337
+ meta = { "path" => File.join(ROOT, graph["feature"], "#{name}.json"), "fingerprint" => fingerprint(payload),
338
+ "graph_revision" => graph["revision"], "state" => state, "needs_recheck" => stale,
339
+ "stale_fingerprint" => stale ? prior["fingerprint"] : nil }.compact
340
+ graph["views"][name] = meta
341
+ payload["view_meta"] = meta
342
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(payload) + "\n")
343
+ end
344
+ meta = { "schema_version" => 2, "feature" => graph["feature"], "graph_revision" => graph["revision"],
345
+ "content_fingerprint" => fingerprint(graph.reject { |key, _| %w[views audit].include?(key) }), "views" => graph["views"], "stale" => graph["stale"] }
346
+ DevflowControlIO.atomic_write(File.join(directory, "graph.meta.json"), JSON.pretty_generate(meta) + "\n")
347
+ end
348
+
349
+ def append_audit!(root, graph, event)
350
+ event = event.merge("schema_version" => 2, "origin" => "core_requirement_graph", "graph_revision" => graph["revision"])
351
+ graph["audit"] << event
352
+ graph["audit"] = graph["audit"].last(200)
353
+ path = File.join(feature_dir(root, graph["feature"]), "control-events.jsonl")
354
+ existing = File.exist?(path) ? File.read(path) : ""
355
+ DevflowControlIO.atomic_write(path, existing + JSON.generate(event) + "\n")
356
+ end
357
+
358
+ def context(graph, slice_id)
359
+ raise ArgumentError, "slice_id is required" unless slice_id.is_a?(String) && slice_id.match?(/\ASL-\d+\z/)
360
+ ids = Set.new([slice_id])
361
+ Array(graph["edges"]).each do |edge|
362
+ next unless edge.is_a?(Hash)
363
+ ids << edge["to"] if edge["from"] == slice_id
364
+ ids << edge["from"] if edge["to"] == slice_id
365
+ end
366
+ nodes = Array(graph["nodes"]).select { |node| ids.include?(node["id"]) }
367
+ payload = { "schema_version" => 2, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
368
+ "slice_id" => slice_id, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| ids.include?(edge["from"]) && ids.include?(edge["to"]) } }
369
+ payload["fingerprint"] = fingerprint(payload)
370
+ payload
371
+ end
372
+
373
+ # 消费者读已持久化 L2 包并强制校验 revision/fingerprint。
374
+ def load_context_package(root, feature, slice_id)
375
+ directory = File.join(feature_dir(root, feature), "context")
376
+ path = File.join(directory, "#{slice_id}.json")
377
+ return nil unless File.exist?(path)
378
+ JSON.parse(File.read(path))
379
+ rescue JSON::ParserError
380
+ nil
381
+ end
382
+ end
383
+
384
+ if __FILE__ == $PROGRAM_NAME
385
+ options = { feature: nil, expected_revision: nil, delta_json: nil, slice_id: nil, node_id: nil, evidence_json: nil, allowed_kinds: nil, write: false, read: false, json: false }
386
+ command = ARGV.shift
387
+ OptionParser.new do |opts|
388
+ opts.banner = "Usage: requirement_graph.rb propose|inspect|validate|context --feature NAME [options]"
389
+ opts.on("--feature NAME") { |value| options[:feature] = value }
390
+ opts.on("--expected-revision N", Integer) { |value| options[:expected_revision] = value }
391
+ opts.on("--delta-json JSON") { |value| options[:delta_json] = value }
392
+ opts.on("--slice-id ID") { |value| options[:slice_id] = value }
393
+ opts.on("--node-id ID") { |value| options[:node_id] = value }
394
+ opts.on("--evidence-json JSON") { |value| options[:evidence_json] = value }
395
+ opts.on("--allowed-kinds LIST") { |value| options[:allowed_kinds] = value }
396
+ opts.on("--write") { options[:write] = true }
397
+ opts.on("--read") { options[:read] = true }
398
+ opts.on("--json") { options[:json] = true }
399
+ end.parse!
400
+ root = Dir.pwd
401
+ lock = %w[propose context confirm].include?(command) && options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
402
+ begin
403
+ graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
404
+ result = case command
405
+ when "inspect"
406
+ { "ok" => true, "path" => relative, "graph" => graph, "errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph) }
407
+ when "validate"
408
+ errors = RequirementGraph.validate(graph)
409
+ { "ok" => errors.empty?, "path" => relative, "revision" => graph["revision"], "errors" => errors, "checkers" => RequirementGraph.checker_results(graph), "stale" => graph["stale"] }
410
+ when "context"
411
+ if options[:read]
412
+ pkg = RequirementGraph.load_context_package(root, graph["feature"], options[:slice_id])
413
+ if pkg.nil?
414
+ { "ok" => false, "reason" => "context_package_missing", "slice_id" => options[:slice_id] }
415
+ elsif pkg["graph_revision"] != graph["revision"] || pkg["fingerprint"].nil?
416
+ { "ok" => false, "reason" => "stale_context_package", "slice_id" => options[:slice_id], "package_revision" => pkg["graph_revision"], "graph_revision" => graph["revision"] }
417
+ else
418
+ { "ok" => true, "path" => relative, "context" => pkg, "fresh" => true }
419
+ end
420
+ else
421
+ ctx = RequirementGraph.context(graph, options[:slice_id])
422
+ if options[:write]
423
+ destination = File.join(RequirementGraph.feature_dir(root, graph["feature"]), "context", "#{options[:slice_id]}.json")
424
+ DevflowControlIO.atomic_write(destination, JSON.pretty_generate(ctx) + "\n")
425
+ ctx["path"] = File.join(RequirementGraph::ROOT, graph["feature"], "context", "#{options[:slice_id]}.json")
426
+ end
427
+ { "ok" => true, "path" => relative, "context" => ctx }
428
+ end
429
+ when "propose"
430
+ raise ArgumentError, "--write is required" unless options[:write]
431
+ raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
432
+ if graph["revision"] != options[:expected_revision]
433
+ { "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
434
+ else
435
+ delta = JSON.parse(options[:delta_json].to_s)
436
+ allowed_kinds = options[:allowed_kinds]&.split(",")&.map(&:strip)
437
+ applied = RequirementGraph.proposal(graph, delta, allowed_kinds)
438
+ graph["revision"] += 1
439
+ stale = Array(graph["stale"]).any?
440
+ RequirementGraph.write_views!(root, graph, stale: stale)
441
+ RequirementGraph.append_audit!(root, graph, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
442
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
443
+ { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
444
+ end
445
+ when "confirm"
446
+ raise ArgumentError, "--write is required" unless options[:write]
447
+ raise ArgumentError, "--node-id is required" unless options[:node_id]
448
+ raise ArgumentError, "--evidence-json is required" unless options[:evidence_json]
449
+ raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
450
+ if graph["revision"] != options[:expected_revision]
451
+ { "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
452
+ else
453
+ evidence = JSON.parse(options[:evidence_json].to_s)
454
+ applied = RequirementGraph.confirm(graph, options[:node_id], evidence)
455
+ graph["revision"] += 1
456
+ stale = Array(graph["stale"]).any?
457
+ RequirementGraph.write_views!(root, graph, stale: stale)
458
+ RequirementGraph.append_audit!(root, graph, { "event" => "graph_confirm", "node_id" => options[:node_id] })
459
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
460
+ { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
461
+ end
462
+ else
463
+ { "ok" => false, "reason" => "invalid_command", "message" => "command must be propose, inspect, validate, context, or confirm" }
464
+ end
465
+ rescue ArgumentError, JSON::ParserError => error
466
+ result = { "ok" => false, "reason" => "invalid_requirement_graph_input", "message" => error.message }
467
+ ensure
468
+ DevflowControlIO.release_lock(lock)
469
+ end
470
+ puts JSON.pretty_generate(result)
471
+ end
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """Fail-closed requirement-to-delivery traceability validation."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ STAGES = ("baseline", "design", "change-plan", "verify")
11
+ DIMENSIONS = ("input", "output", "behavior", "orchestration", "failure", "verification")
12
+
13
+
14
+ def fail(errors: list[str], message: str) -> None:
15
+ errors.append(message)
16
+
17
+
18
+ def references(items: object) -> set[str]:
19
+ if not isinstance(items, list):
20
+ return set()
21
+ result = set()
22
+ for item in items:
23
+ if isinstance(item, str):
24
+ result.add(item)
25
+ elif isinstance(item, dict) and isinstance(item.get("id"), str):
26
+ result.add(item["id"])
27
+ return result
28
+
29
+
30
+ def check(data: dict, stage: str) -> list[str]:
31
+ errors: list[str] = []
32
+ requirements = data.get("requirements")
33
+ if data.get("schema_version") != 1:
34
+ fail(errors, "schema_version must be 1")
35
+ if not isinstance(data.get("feature"), str) or not data["feature"].strip():
36
+ fail(errors, "feature is required")
37
+ if not isinstance(requirements, list) or not requirements:
38
+ return errors + ["requirements must be a non-empty array"]
39
+
40
+ active: set[str] = set()
41
+ known: set[str] = set()
42
+ for req in requirements:
43
+ if not isinstance(req, dict):
44
+ fail(errors, "every requirement must be an object")
45
+ continue
46
+ rid = req.get("id")
47
+ if not isinstance(rid, str) or not rid.startswith("REQ-"):
48
+ fail(errors, f"invalid requirement id: {rid!r}")
49
+ continue
50
+ known.add(rid)
51
+ if req.get("status", "confirmed") in {"confirmed", "approved"} and req.get("scope", "in") == "in":
52
+ active.add(rid)
53
+ dimensions = req.get("dimensions")
54
+ if not isinstance(dimensions, dict):
55
+ fail(errors, f"{rid}: dimensions is required")
56
+ else:
57
+ missing = [d for d in DIMENSIONS if not isinstance(dimensions.get(d), str) or not dimensions[d].strip()]
58
+ if missing:
59
+ fail(errors, f"{rid}: missing dimensions: {', '.join(missing)}")
60
+
61
+ if stage == "baseline":
62
+ return errors
63
+
64
+ designs = data.get("designs")
65
+ if not isinstance(designs, list) or not designs:
66
+ fail(errors, "designs must be a non-empty array after baseline")
67
+ covered_by_design = set()
68
+ for item in designs or []:
69
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str):
70
+ fail(errors, "every design needs an id")
71
+ continue
72
+ refs = references(item.get("requirements"))
73
+ unknown = refs - known
74
+ if unknown:
75
+ fail(errors, f"{item['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
76
+ covered_by_design |= refs
77
+ if item.get("kind") == "api":
78
+ contract = item.get("contract")
79
+ if not isinstance(contract, dict) or any(not contract.get(k) for k in ("request", "response", "errors", "behavior")):
80
+ fail(errors, f"{item['id']}: API design must include request, response, errors and behavior")
81
+ missing = active - covered_by_design
82
+ if missing:
83
+ fail(errors, "requirements missing design coverage: " + ", ".join(sorted(missing)))
84
+ if stage == "design":
85
+ return errors
86
+
87
+ tasks = data.get("tasks")
88
+ if not isinstance(tasks, list) or not tasks:
89
+ fail(errors, "tasks must be a non-empty array after design")
90
+ covered_by_task = set()
91
+ for task in tasks or []:
92
+ if not isinstance(task, dict) or not isinstance(task.get("id"), str):
93
+ fail(errors, "every task needs an id")
94
+ continue
95
+ refs = task.get("requirements")
96
+ if not isinstance(refs, list) or not refs:
97
+ fail(errors, f"{task['id']}: requirements is required")
98
+ else:
99
+ covered_by_task.update(refs)
100
+ unknown = set(refs) - known
101
+ if unknown:
102
+ fail(errors, f"{task['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
103
+ criteria = task.get("acceptance")
104
+ if not isinstance(criteria, dict):
105
+ fail(errors, f"{task['id']}: acceptance is required")
106
+ else:
107
+ missing = [d for d in DIMENSIONS if not isinstance(criteria.get(d), str) or not criteria[d].strip()]
108
+ if missing:
109
+ fail(errors, f"{task['id']}: missing acceptance dimensions: {', '.join(missing)}")
110
+ missing = active - covered_by_task
111
+ if missing:
112
+ fail(errors, "requirements missing task coverage: " + ", ".join(sorted(missing)))
113
+ if stage == "change-plan":
114
+ return errors
115
+
116
+ verification = data.get("verification")
117
+ if not isinstance(verification, list) or not verification:
118
+ fail(errors, "verification must be a non-empty array after change-plan")
119
+ covered_by_verification = set()
120
+ for item in verification or []:
121
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str):
122
+ fail(errors, "every verification item needs an id")
123
+ continue
124
+ refs = item.get("requirements")
125
+ if not isinstance(refs, list) or not refs:
126
+ fail(errors, f"{item['id']}: requirements is required")
127
+ else:
128
+ covered_by_verification.update(refs)
129
+ unknown = set(refs) - known
130
+ if unknown:
131
+ fail(errors, f"{item['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
132
+ if not isinstance(item.get("evidence"), str) or not item["evidence"].strip():
133
+ fail(errors, f"{item['id']}: evidence is required")
134
+ missing = active - covered_by_verification
135
+ if missing:
136
+ fail(errors, "requirements missing verification coverage: " + ", ".join(sorted(missing)))
137
+ return errors
138
+
139
+
140
+ def main() -> int:
141
+ parser = argparse.ArgumentParser()
142
+ parser.add_argument("--file", required=True)
143
+ parser.add_argument("--stage", choices=STAGES, required=True)
144
+ args = parser.parse_args()
145
+ try:
146
+ data = json.loads(Path(args.file).read_text(encoding="utf-8"))
147
+ except (OSError, json.JSONDecodeError) as exc:
148
+ print(f"TRACEABILITY_FAIL: cannot read manifest: {exc}", file=sys.stderr)
149
+ return 2
150
+ if not isinstance(data, dict):
151
+ print("TRACEABILITY_FAIL: manifest root must be an object", file=sys.stderr)
152
+ return 2
153
+ errors = check(data, args.stage)
154
+ result = {"status": "passed" if not errors else "failed", "stage": args.stage, "errors": errors}
155
+ print(json.dumps(result, ensure_ascii=False))
156
+ return 0 if not errors else 1
157
+
158
+
159
+ if __name__ == "__main__":
160
+ raise SystemExit(main())