@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.
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +179 -54
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +93 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +2 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +111 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +471 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +58 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +34 -0
- package/plugins/devflow-requirements/skills/requirement-feasibility-review/SKILL.md +4 -0
- package/plugins/devflow-requirements/skills/workflow-domain-modeling/SKILL.md +4 -0
- package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +4 -0
- package/plugins/devflow-requirements/skills/workflow-requirement-triage/SKILL.md +4 -0
- package/src/cli.ts +2 -1
- 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
|
|
@@ -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"]
|
package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml
CHANGED
|
@@ -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
|
|