@guanlin4924/devflow-pi 0.1.15 → 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.
@@ -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
@@ -6,6 +6,7 @@ require "optparse"
6
6
  require "yaml"
7
7
  require_relative "control_io"
8
8
  require_relative "phase_ownership"
9
+ require_relative "requirement_graph"
9
10
 
10
11
  module WorkflowStateValidator
11
12
  REQUIRED_STATE_FIELDS = %w[
@@ -76,6 +77,26 @@ module WorkflowStateValidator
76
77
  validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status)
77
78
  validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
78
79
  validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
80
+ graph_gate = workflow.dig("governance", "requirement_graph_gate").to_s
81
+ governed_steps = %w[requirement-baseline api-contract integration-verify change-plan execute verify]
82
+ # A historical completed state is never rolled back solely because it has
83
+ # no v2 graph/evidence. Its first new governed step, however, must satisfy
84
+ # the new contract before it can advance.
85
+ strict_requirement_graph = graph_gate == "enforced" && (governed_steps.include?(state["current_step"]) || completed_ids.any? { |id| governed_steps.include?(id) })
86
+ observe_requirement_graph(issues, root, state, strict: strict_requirement_graph)
87
+ # Migration is intentionally non-destructive: pre-v2 completed steps are
88
+ # visible as unattested, never rewritten, rolled back, or auto-rerun.
89
+ if state["governance_started_revision"].nil?
90
+ legacy_steps = completed_ids.select { |id| governed_steps.include?(id) }
91
+ if legacy_steps.any?
92
+ issues << issue(
93
+ "warning",
94
+ "legacy_unattested",
95
+ "Historical governed steps predate Evidence Pack v2 and are report-only",
96
+ steps: legacy_steps
97
+ )
98
+ end
99
+ end
79
100
 
80
101
  ok = issues.none? { |entry| entry["severity"] == "error" }
81
102
  result(ok, state_path, workflow_path, registry_path, issues)
@@ -263,6 +284,43 @@ module WorkflowStateValidator
263
284
  end
264
285
  end
265
286
 
287
+ # P1 observation mode: existing projects must not become invalid merely
288
+ # because they predate governance graphs. Once a graph exists, surface its
289
+ # structural failures through state inspection so operators can fix them
290
+ # before the corresponding checkpoint is upgraded to fail-closed.
291
+ def observe_requirement_graph(issues, root, state, strict: false)
292
+ path, relative = RequirementGraph.graph_path(root, state["current_feature"])
293
+ unless File.exist?(path)
294
+ issues << issue(strict ? "error" : "warning", "requirement_graph_missing", "Requirement governance graph is missing", path: relative) if strict
295
+ return
296
+ end
297
+
298
+ graph, = RequirementGraph.load_graph(root, state["current_feature"])
299
+ errors = RequirementGraph.validate(graph)
300
+ if errors.any?
301
+ issues << issue(
302
+ strict ? "error" : "warning",
303
+ "requirement_graph_observe_failed",
304
+ "Requirement governance graph has structural gaps (observation mode)",
305
+ path: relative,
306
+ graph_revision: graph["revision"],
307
+ errors: errors
308
+ )
309
+ end
310
+ return if Array(graph["stale"]).empty?
311
+
312
+ issues << issue(
313
+ strict ? "error" : "warning",
314
+ "requirement_graph_needs_recheck",
315
+ "Requirement governance graph has downstream nodes awaiting recheck (observation mode)",
316
+ path: relative,
317
+ graph_revision: graph["revision"],
318
+ stale: graph["stale"]
319
+ )
320
+ rescue ArgumentError => error
321
+ issues << issue("warning", "requirement_graph_observe_failed", error.message)
322
+ end
323
+
266
324
  def validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, waiting_checkpoint)
267
325
  Array(step["outputs"]).each do |output|
268
326
  next unless output["required"]
@@ -13,6 +13,40 @@ phase_scope:
13
13
  failure_branch_enabled: true
14
14
  parallel_window_enabled: true
15
15
 
16
+ # P2 preset: new work fail-closes at the defined checkpoints. Existing STATE
17
+ # files without governance_started_revision remain legacy_unattested and are
18
+ # only reported until their next newly-started governed step.
19
+ governance:
20
+ requirement_graph_gate: enforced
21
+ enforced_checkpoints:
22
+ - CP-1
23
+ - CP-API
24
+ - CP-2
25
+ - CP-3
26
+ step_checkers:
27
+ requirement-baseline:
28
+ - requirement-graph-check
29
+ - slice-independence-check
30
+ - acceptance-coverage-check
31
+ - change-impact-check
32
+ api-contract:
33
+ - requirement-graph-check
34
+ - contract-coverage-check
35
+ - change-impact-check
36
+ integration-verify:
37
+ - requirement-graph-check
38
+ - slice-independence-check
39
+ - contract-coverage-check
40
+ - change-impact-check
41
+ change-plan:
42
+ - requirement-graph-check
43
+ - slice-independence-check
44
+ - contract-coverage-check
45
+ - change-impact-check
46
+ verify:
47
+ - acceptance-coverage-check
48
+ - change-impact-check
49
+
16
50
  artifact_registry:
17
51
  source: ".planning/artifacts/index.yaml"
18
52
  path_key: path
@@ -204,6 +204,10 @@ Before accepting the rebuttal or retracting the finding, re-open the cited raw s
204
204
 
205
205
  ## Quality Bar
206
206
 
207
+ ## Governance Graph Candidate
208
+
209
+ Submit `risk` and `decision` candidates through `devflow_requirement_graph`, and add missing `behavior` candidates only when backed by a cited source. A `pass` review cannot promote candidates, clear stale nodes, or claim machine validation; it supplies structured candidates and evidence for the Core gate and human checkpoint.
210
+
207
211
  The review must make it easy to trace every conclusion back to evidence. Include:
208
212
 
209
213
  - flow inventory and critical capability chains;
@@ -130,6 +130,10 @@ Accepted
130
130
 
131
131
  ## Meta Output
132
132
 
133
+ ## Governance Graph Candidate
134
+
135
+ Submit bounded candidates through `devflow_requirement_graph`: use `term` for shared language and `behavior` for actor, precondition, state transition, invariant, writes, side effects, failure, and recovery. Link candidates only with returned IDs. Never directly edit the canonical graph or mark a node confirmed; unresolved ownership remains a candidate and is reported in metadata.
136
+
133
137
  `领域模型记录.meta.json` minimum fields:
134
138
 
135
139
  ```json
@@ -727,6 +727,10 @@ Completion gate: baseline and baseline.meta.json are written and contain no unre
727
727
 
728
728
  ### 7.4 产物完整性检查
729
729
 
730
+ ### 7.4.1 Governance Graph Candidate
731
+
732
+ Before claiming baseline readiness, submit `requirement`、`rule`、`boundary`、`slice` and `acceptance` candidates through `devflow_requirement_graph`. Each requirement candidate must cite source IDs and reference behavior/acceptance IDs returned by the writer. Run `devflow_requirement_graph(action="validate")` and record its result in baseline metadata. Do not directly create `governance.json`, allocate IDs, or set candidate status to `confirmed`.
733
+
730
734
  ```markdown
731
735
  - [ ] 每个原始需求映射到 FP-*/BR-*/BC-* 或 out-of-scope
732
736
  - [ ] 追溯表完整,来源→FP 映射可验证
@@ -191,6 +191,10 @@ Ask at most one question, only when route cannot be decided without it. If the n
191
191
 
192
192
  ## Completion Rule
193
193
 
194
+ ## Governance Graph Candidate
195
+
196
+ Before completion, inspect `devflow_requirement_graph` for the current feature. Submit only `source` or `code` candidate deltas for source material and repository facts discovered here. Read the returned graph revision after every accepted delta. Do not invent stable IDs, set a candidate to `confirmed`, or write `governance.json` directly.
197
+
194
198
  Only tell `workflow-orchestrator` to complete `requirement-triage` after writing:
195
199
 
196
200
  ```json
package/src/cli.ts CHANGED
@@ -14,7 +14,7 @@ function usage(): string {
14
14
  " devflow-pi init [project]",
15
15
  " devflow-pi doctor [project]",
16
16
  " devflow-pi usage [project]",
17
- " devflow-pi [project] [--resume [session-id]] [--prompt <text>] [--mcp]",
17
+ " devflow-pi [project] [--resume [session-id]] [--prompt <text>] [--mcp] [--yolo]",
18
18
  ].join("\n");
19
19
  }
20
20
 
@@ -76,6 +76,7 @@ async function main(): Promise<void> {
76
76
  originalOmpAgentDir,
77
77
  resume: optionalOption(args, "--resume"),
78
78
  enableMCP: args.includes("--mcp"),
79
+ approvalMode: args.includes("--yolo") ? "yolo" : "write",
79
80
  });
80
81
  }
81
82