@guanlin4924/devflow-pi 0.1.16 → 0.1.18
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/README.md +2 -0
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +297 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +3 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_assessment.rb +458 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +256 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +155 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +176 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +46 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +187 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/runtime_context_collector.rb +210 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/stale_check.rb +56 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/static_runtime_context_collector.rb +518 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +83 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workload_constraint.rb +57 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +54 -2
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -2
- package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +17 -1
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +37 -0
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/agents/openai.yaml +4 -0
- package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +7 -2
- package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -1
|
@@ -10,15 +10,19 @@ require "optparse"
|
|
|
10
10
|
require "pathname"
|
|
11
11
|
require "set"
|
|
12
12
|
require_relative "control_io"
|
|
13
|
+
require_relative "architecture_constraints"
|
|
13
14
|
|
|
14
15
|
module RequirementGraph
|
|
15
|
-
|
|
16
|
+
# v3 adds the two narrowly-scoped architecture inputs. Existing v1/v2
|
|
17
|
+
# graphs are migrated in memory and re-written only by this Core writer.
|
|
18
|
+
SCHEMA_VERSION = 3
|
|
19
|
+
SUPPORTED_SCHEMA_VERSIONS = [1, 2, SCHEMA_VERSION].freeze
|
|
16
20
|
ROOT = ".planning/requirements"
|
|
17
21
|
KINDS = {
|
|
18
22
|
"source" => "SRC", "code" => "CODE", "term" => "TERM", "object" => "OBJECT",
|
|
19
23
|
"behavior" => "BEH", "requirement" => "REQ", "rule" => "BR", "boundary" => "BC",
|
|
20
24
|
"slice" => "SL", "risk" => "RISK", "decision" => "DECISION", "acceptance" => "AC",
|
|
21
|
-
"api" => "API"
|
|
25
|
+
"api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC"
|
|
22
26
|
}.freeze
|
|
23
27
|
VIEW_NAMES = %w[evidence definitions behavior-graph requirement-graph slices acceptance traceability].freeze
|
|
24
28
|
FEATURE_INVALID = /[\\\/\x00-\x1F\x7F]/
|
|
@@ -33,8 +37,8 @@ module RequirementGraph
|
|
|
33
37
|
def graph_path(root, feature)
|
|
34
38
|
raise ArgumentError, "invalid feature" unless valid_feature?(feature)
|
|
35
39
|
relative = File.join(ROOT, feature, "governance.json")
|
|
36
|
-
absolute = File.expand_path(relative, root)
|
|
37
40
|
root_real = File.realpath(root)
|
|
41
|
+
absolute = File.expand_path(relative, root_real)
|
|
38
42
|
raise ArgumentError, "graph path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
39
43
|
[absolute, relative]
|
|
40
44
|
end
|
|
@@ -72,7 +76,7 @@ module RequirementGraph
|
|
|
72
76
|
path, relative = graph_path(root, feature)
|
|
73
77
|
return [empty_graph(feature), path, relative] unless File.exist?(path)
|
|
74
78
|
parsed = JSON.parse(File.read(path))
|
|
75
|
-
valid_shape = parsed.is_a?(Hash) &&
|
|
79
|
+
valid_shape = parsed.is_a?(Hash) && SUPPORTED_SCHEMA_VERSIONS.include?(parsed["schema_version"]) && parsed["feature"] == feature &&
|
|
76
80
|
parsed["revision"].is_a?(Integer) && parsed["nodes"].is_a?(Array) && parsed["edges"].is_a?(Array) && parsed["stale"].is_a?(Array)
|
|
77
81
|
raise ArgumentError, "invalid governance graph" unless valid_shape
|
|
78
82
|
[normalize_legacy_graph(parsed), path, relative]
|
|
@@ -154,6 +158,8 @@ module RequirementGraph
|
|
|
154
158
|
raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
|
|
155
159
|
data = normalize_data(delta["data"])
|
|
156
160
|
node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
|
|
161
|
+
candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node)
|
|
162
|
+
raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
|
|
157
163
|
set_node_fingerprint!(node)
|
|
158
164
|
graph["nodes"] << node
|
|
159
165
|
{ "operation" => "add", "node" => node }
|
|
@@ -169,8 +175,13 @@ module RequirementGraph
|
|
|
169
175
|
id = delta["id"]
|
|
170
176
|
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
|
|
171
177
|
raise ArgumentError, "update requires a known node id" unless node
|
|
178
|
+
if (%w[workload_shape workload_constraint].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
|
|
179
|
+
raise ArgumentError, "confirmed workload nodes and architecture decisions are immutable; add a new candidate and relink consumers"
|
|
180
|
+
end
|
|
172
181
|
data = normalize_data(delta["data"])
|
|
173
182
|
node["data"] = node.fetch("data", {}).merge(data)
|
|
183
|
+
candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node)
|
|
184
|
+
raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
|
|
174
185
|
set_node_fingerprint!(node)
|
|
175
186
|
clear_stale_for!(graph, id)
|
|
176
187
|
mode = delta["propagation"] || "full"
|
|
@@ -184,13 +195,18 @@ module RequirementGraph
|
|
|
184
195
|
end
|
|
185
196
|
|
|
186
197
|
# Core-only 审核转换:candidate → confirmed,要求人工 CP 记录或运行证据。
|
|
187
|
-
def confirm(graph, node_id, evidence)
|
|
198
|
+
def confirm(graph, node_id, evidence, confirmed_interactively: false)
|
|
188
199
|
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node_id }
|
|
189
200
|
raise ArgumentError, "confirm requires a known node id" unless node
|
|
190
201
|
raise ArgumentError, "node is already confirmed" if node["status"] == "confirmed"
|
|
191
202
|
unless evidence.is_a?(Hash) && (evidence["cp_record"] || evidence["evidence_ref"])
|
|
192
203
|
raise ArgumentError, "confirm requires cp_record or evidence_ref"
|
|
193
204
|
end
|
|
205
|
+
if (%w[workload_shape workload_constraint].include?(node["kind"]) || architecture_control_node?(node)) && !confirmed_interactively
|
|
206
|
+
raise ArgumentError, "workload and architecture-control confirmation requires --confirmed-interactively from the Core confirmation dialog"
|
|
207
|
+
end
|
|
208
|
+
confirmation_errors = workload_confirmation_errors(graph, node, evidence) + architecture_decision_confirmation_errors(graph, node) + architecture_risk_confirmation_errors(graph, node)
|
|
209
|
+
raise ArgumentError, confirmation_errors.join("; ") if confirmation_errors.any?
|
|
194
210
|
node["status"] = "confirmed"
|
|
195
211
|
node["confirmation"] = { "origin" => "core", "evidence" => evidence }
|
|
196
212
|
set_node_fingerprint!(node)
|
|
@@ -230,6 +246,152 @@ module RequirementGraph
|
|
|
230
246
|
Array(refs).all? { |ref| ref.is_a?(String) && known.include?(ref) && prefixes.any? { |prefix| ref.start_with?("#{prefix}-") } }
|
|
231
247
|
end
|
|
232
248
|
|
|
249
|
+
def incoming_edge?(graph, from, to, relation)
|
|
250
|
+
Array(graph["edges"]).any? do |edge|
|
|
251
|
+
edge.is_a?(Hash) && edge["from"] == from && edge["to"] == to && edge["relation"] == relation
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# WSH/WLC are graph-owned facts, not a second unbounded document schema.
|
|
256
|
+
# The candidate validator owns field rules; this layer adds only the facts
|
|
257
|
+
# it can prove: graph references, traceability edges, and confirmation state.
|
|
258
|
+
def workload_node_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
|
|
259
|
+
return [] unless %w[workload_shape workload_constraint].include?(node["kind"])
|
|
260
|
+
|
|
261
|
+
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
262
|
+
payload = data.merge("feature" => graph["feature"], "status" => node["status"])
|
|
263
|
+
errors =
|
|
264
|
+
if node["kind"] == "workload_shape"
|
|
265
|
+
ArchitectureConstraints.validate_shape(payload, allow_confirmed: true)
|
|
266
|
+
else
|
|
267
|
+
ArchitectureConstraints.validate_constraint(payload, allow_confirmed: true)
|
|
268
|
+
end
|
|
269
|
+
errors = errors.map { |error| "#{node["id"]}: #{error}" }
|
|
270
|
+
|
|
271
|
+
if node["kind"] == "workload_shape"
|
|
272
|
+
refs = Array(data["source_refs"]) + Array(data["behavior_refs"])
|
|
273
|
+
refs.uniq.each do |ref|
|
|
274
|
+
expected = ref.to_s.start_with?("BEH-") ? %w[BEH] : %w[REQ BEH API]
|
|
275
|
+
errors << "#{node["id"]}: workload source #{ref} must exist in the graph" unless refs_known?(graph, [ref], expected)
|
|
276
|
+
errors << "#{node["id"]}: workload source #{ref} must link with shapes_workload" if require_edges && !incoming_edge?(graph, ref, node["id"], "shapes_workload")
|
|
277
|
+
errors << "#{node["id"]}: workload source #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
278
|
+
end
|
|
279
|
+
else
|
|
280
|
+
workload_ref = data["workload_ref"]
|
|
281
|
+
errors << "#{node["id"]}: workload_ref #{workload_ref} must exist in the graph" unless refs_known?(graph, [workload_ref], %w[BEH SL API])
|
|
282
|
+
errors << "#{node["id"]}: workload_ref #{workload_ref} must link with constrains_workload" if require_edges && !incoming_edge?(graph, workload_ref, node["id"], "constrains_workload")
|
|
283
|
+
errors << "#{node["id"]}: workload_ref #{workload_ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, workload_ref)
|
|
284
|
+
|
|
285
|
+
evidence = data["evidence"]
|
|
286
|
+
if evidence.is_a?(Hash) && evidence["kind"] == "source_file"
|
|
287
|
+
evidence_ref = evidence["ref"]
|
|
288
|
+
errors << "#{node["id"]}: source_file evidence.ref must reference SRC/CODE" unless refs_known?(graph, [evidence_ref], %w[SRC CODE])
|
|
289
|
+
errors << "#{node["id"]}: source_file evidence.ref must be confirmed" if require_confirmed_inputs && !confirmed?(graph, evidence_ref)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
errors.uniq
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def workload_confirmation_errors(graph, node, evidence)
|
|
296
|
+
errors = workload_node_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
|
|
297
|
+
if node["kind"] == "workload_constraint" && %w[approved_assumption unknown].include?(node.dig("data", "source_status")) && !evidence["cp_record"]
|
|
298
|
+
errors << "#{node["id"]}: #{node.dig("data", "source_status")} confirmation requires cp_record"
|
|
299
|
+
end
|
|
300
|
+
errors
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def architecture_option_decision?(node)
|
|
304
|
+
node["kind"] == "decision" && node.dig("data", "decision_type") == "architecture_option_comparison"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Keep architecture comparison inside the existing DECISION vocabulary. The
|
|
308
|
+
# typed payload prevents a prose-only "A/B" list from masquerading as a
|
|
309
|
+
# decision: every option needs a mechanism, fact-bound evidence and tradeoffs.
|
|
310
|
+
def architecture_decision_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
|
|
311
|
+
return [] unless architecture_option_decision?(node)
|
|
312
|
+
|
|
313
|
+
data = node.fetch("data", {})
|
|
314
|
+
errors = []
|
|
315
|
+
subject_refs = data["subject_refs"]
|
|
316
|
+
errors << "#{node["id"]}: architecture decision subject_refs must be a non-empty array" unless subject_refs.is_a?(Array) && !subject_refs.empty?
|
|
317
|
+
Array(subject_refs).each do |ref|
|
|
318
|
+
errors << "#{node["id"]}: architecture decision subject_ref #{ref} must reference WSH/WLC" unless refs_known?(graph, [ref], %w[WSH WLC])
|
|
319
|
+
relation = ref.to_s.start_with?("WSH-") ? "informs_decision" : "constrains_decision"
|
|
320
|
+
errors << "#{node["id"]}: architecture decision subject_ref #{ref} must link with #{relation}" if require_edges && !incoming_edge?(graph, ref, node["id"], relation)
|
|
321
|
+
errors << "#{node["id"]}: architecture decision subject_ref #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
comparison = data["comparison"]
|
|
325
|
+
unless comparison.is_a?(Hash)
|
|
326
|
+
errors << "#{node["id"]}: architecture decision comparison is required"
|
|
327
|
+
return errors.uniq
|
|
328
|
+
end
|
|
329
|
+
mode = comparison["mode"]
|
|
330
|
+
errors << "#{node["id"]}: architecture decision comparison.mode is invalid" unless %w[multiple_options single_path].include?(mode)
|
|
331
|
+
options = comparison["options"]
|
|
332
|
+
errors << "#{node["id"]}: architecture decision comparison.options must be an array" unless options.is_a?(Array)
|
|
333
|
+
options = Array(options)
|
|
334
|
+
errors << "#{node["id"]}: multiple_options requires at least two materially different options" if mode == "multiple_options" && options.length < 2
|
|
335
|
+
errors << "#{node["id"]}: single_path requires exactly one option" if mode == "single_path" && options.length != 1
|
|
336
|
+
option_ids = []
|
|
337
|
+
options.each do |option|
|
|
338
|
+
unless option.is_a?(Hash)
|
|
339
|
+
errors << "#{node["id"]}: comparison option must be an object"
|
|
340
|
+
next
|
|
341
|
+
end
|
|
342
|
+
option_key = option["option_key"].to_s
|
|
343
|
+
option_ids << option_key unless option_key.empty?
|
|
344
|
+
label = option_key.empty? ? "<missing>" : option_key
|
|
345
|
+
errors << "#{node["id"]}: comparison option option_key is required" if option_key.empty?
|
|
346
|
+
errors << "#{node["id"]}: option #{label} requires mechanism" unless present?(option["mechanism"])
|
|
347
|
+
errors << "#{node["id"]}: option #{label} requires non-empty tradeoffs" unless option["tradeoffs"].is_a?(Array) && !option["tradeoffs"].empty?
|
|
348
|
+
refs = option["fact_refs"]
|
|
349
|
+
errors << "#{node["id"]}: option #{label} requires fact_refs" unless refs.is_a?(Array) && !refs.empty?
|
|
350
|
+
Array(refs).each do |ref|
|
|
351
|
+
errors << "#{node["id"]}: option #{label} fact_ref #{ref} must reference WSH/WLC" unless refs_known?(graph, [ref], %w[WSH WLC])
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
errors << "#{node["id"]}: architecture decision option ids must be unique" unless option_ids.uniq.length == option_ids.length
|
|
355
|
+
recommendation = comparison["recommendation_option_id"]
|
|
356
|
+
errors << "#{node["id"]}: architecture decision recommendation_option_id must name a listed option" unless option_ids.include?(recommendation)
|
|
357
|
+
errors.uniq
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def architecture_decision_confirmation_errors(graph, node)
|
|
361
|
+
architecture_decision_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def architecture_option_risk?(node)
|
|
365
|
+
node["kind"] == "risk" && node.dig("data", "risk_type") == "architecture_option_risk"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def architecture_risk_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
|
|
369
|
+
return [] unless architecture_option_risk?(node)
|
|
370
|
+
|
|
371
|
+
data = node.fetch("data", {})
|
|
372
|
+
errors = []
|
|
373
|
+
refs = data["subject_refs"]
|
|
374
|
+
errors << "#{node["id"]}: architecture risk subject_refs must be a non-empty array" unless refs.is_a?(Array) && !refs.empty?
|
|
375
|
+
Array(refs).each do |ref|
|
|
376
|
+
errors << "#{node["id"]}: architecture risk subject_ref #{ref} must reference DECISION/WSH/WLC" unless refs_known?(graph, [ref], %w[DECISION WSH WLC])
|
|
377
|
+
errors << "#{node["id"]}: architecture risk subject_ref #{ref} must link with has_architecture_risk" if require_edges && !incoming_edge?(graph, ref, node["id"], "has_architecture_risk")
|
|
378
|
+
errors << "#{node["id"]}: architecture risk subject_ref #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
379
|
+
end
|
|
380
|
+
%w[failure_mode impact mitigation owner review_condition].each do |field|
|
|
381
|
+
errors << "#{node["id"]}: architecture risk requires #{field}" unless present?(data[field])
|
|
382
|
+
end
|
|
383
|
+
errors << "#{node["id"]}: architecture risk treatment is invalid" unless %w[mitigate accept].include?(data["treatment"])
|
|
384
|
+
errors.uniq
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def architecture_risk_confirmation_errors(graph, node)
|
|
388
|
+
architecture_risk_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def architecture_control_node?(node)
|
|
392
|
+
architecture_option_decision?(node) || architecture_option_risk?(node)
|
|
393
|
+
end
|
|
394
|
+
|
|
233
395
|
def confirmed?(graph, id)
|
|
234
396
|
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
|
|
235
397
|
!node.nil? && node["status"] == "confirmed"
|
|
@@ -264,6 +426,18 @@ module RequirementGraph
|
|
|
264
426
|
required(results["contract-coverage-check"], node, %w[request response errors permission data_scope compatibility behavior_ref requirement_refs])
|
|
265
427
|
results["contract-coverage-check"] << "#{node["id"]}: behavior_ref must reference BEH" unless refs_known?(graph, [node.dig("data", "behavior_ref")], ["BEH"])
|
|
266
428
|
results["contract-coverage-check"] << "#{node["id"]}: requirement_refs must reference REQ" unless refs_known?(graph, node.dig("data", "requirement_refs"), ["REQ"])
|
|
429
|
+
when "decision"
|
|
430
|
+
results["requirement-graph-check"].concat(
|
|
431
|
+
architecture_decision_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
432
|
+
)
|
|
433
|
+
when "risk"
|
|
434
|
+
results["requirement-graph-check"].concat(
|
|
435
|
+
architecture_risk_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
436
|
+
)
|
|
437
|
+
when "workload_shape", "workload_constraint"
|
|
438
|
+
results["requirement-graph-check"].concat(
|
|
439
|
+
workload_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
440
|
+
)
|
|
267
441
|
when "acceptance"
|
|
268
442
|
required(results["acceptance-coverage-check"], node, %w[requirement_ref verification_types])
|
|
269
443
|
types = Array(node.dig("data", "verification_types"))
|
|
@@ -316,13 +490,13 @@ module RequirementGraph
|
|
|
316
490
|
when "evidence" then %w[source code]
|
|
317
491
|
when "definitions" then %w[term object rule boundary]
|
|
318
492
|
when "behavior-graph" then %w[behavior]
|
|
319
|
-
when "requirement-graph", "traceability" then %w[requirement rule boundary api]
|
|
493
|
+
when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint]
|
|
320
494
|
when "slices" then %w[slice]
|
|
321
495
|
when "acceptance" then %w[acceptance risk decision]
|
|
322
496
|
else []
|
|
323
497
|
end
|
|
324
498
|
nodes = Array(graph["nodes"]).select { |node| kinds.include?(node["kind"]) }
|
|
325
|
-
{ "schema_version" =>
|
|
499
|
+
{ "schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
|
|
326
500
|
"view" => name, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| nodes.any? { |node| node["id"] == edge["from"] || node["id"] == edge["to"] } } }
|
|
327
501
|
end
|
|
328
502
|
|
|
@@ -341,13 +515,13 @@ module RequirementGraph
|
|
|
341
515
|
payload["view_meta"] = meta
|
|
342
516
|
DevflowControlIO.atomic_write(path, JSON.pretty_generate(payload) + "\n")
|
|
343
517
|
end
|
|
344
|
-
meta = { "schema_version" =>
|
|
518
|
+
meta = { "schema_version" => SCHEMA_VERSION, "feature" => graph["feature"], "graph_revision" => graph["revision"],
|
|
345
519
|
"content_fingerprint" => fingerprint(graph.reject { |key, _| %w[views audit].include?(key) }), "views" => graph["views"], "stale" => graph["stale"] }
|
|
346
520
|
DevflowControlIO.atomic_write(File.join(directory, "graph.meta.json"), JSON.pretty_generate(meta) + "\n")
|
|
347
521
|
end
|
|
348
522
|
|
|
349
523
|
def append_audit!(root, graph, event)
|
|
350
|
-
event = event.merge("schema_version" =>
|
|
524
|
+
event = event.merge("schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "graph_revision" => graph["revision"])
|
|
351
525
|
graph["audit"] << event
|
|
352
526
|
graph["audit"] = graph["audit"].last(200)
|
|
353
527
|
path = File.join(feature_dir(root, graph["feature"]), "control-events.jsonl")
|
|
@@ -364,7 +538,7 @@ module RequirementGraph
|
|
|
364
538
|
ids << edge["from"] if edge["to"] == slice_id
|
|
365
539
|
end
|
|
366
540
|
nodes = Array(graph["nodes"]).select { |node| ids.include?(node["id"]) }
|
|
367
|
-
payload = { "schema_version" =>
|
|
541
|
+
payload = { "schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
|
|
368
542
|
"slice_id" => slice_id, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| ids.include?(edge["from"]) && ids.include?(edge["to"]) } }
|
|
369
543
|
payload["fingerprint"] = fingerprint(payload)
|
|
370
544
|
payload
|
|
@@ -382,7 +556,7 @@ module RequirementGraph
|
|
|
382
556
|
end
|
|
383
557
|
|
|
384
558
|
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 }
|
|
559
|
+
options = { feature: nil, expected_revision: nil, delta_json: nil, slice_id: nil, node_id: nil, evidence_json: nil, allowed_kinds: nil, confirmed_interactively: false, write: false, read: false, json: false }
|
|
386
560
|
command = ARGV.shift
|
|
387
561
|
OptionParser.new do |opts|
|
|
388
562
|
opts.banner = "Usage: requirement_graph.rb propose|inspect|validate|context --feature NAME [options]"
|
|
@@ -393,6 +567,7 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
393
567
|
opts.on("--node-id ID") { |value| options[:node_id] = value }
|
|
394
568
|
opts.on("--evidence-json JSON") { |value| options[:evidence_json] = value }
|
|
395
569
|
opts.on("--allowed-kinds LIST") { |value| options[:allowed_kinds] = value }
|
|
570
|
+
opts.on("--confirmed-interactively") { options[:confirmed_interactively] = true }
|
|
396
571
|
opts.on("--write") { options[:write] = true }
|
|
397
572
|
opts.on("--read") { options[:read] = true }
|
|
398
573
|
opts.on("--json") { options[:json] = true }
|
|
@@ -451,7 +626,7 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
451
626
|
{ "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
|
|
452
627
|
else
|
|
453
628
|
evidence = JSON.parse(options[:evidence_json].to_s)
|
|
454
|
-
applied = RequirementGraph.confirm(graph, options[:node_id], evidence)
|
|
629
|
+
applied = RequirementGraph.confirm(graph, options[:node_id], evidence, confirmed_interactively: options[:confirmed_interactively])
|
|
455
630
|
graph["revision"] += 1
|
|
456
631
|
stale = Array(graph["stale"]).any?
|
|
457
632
|
RequirementGraph.write_views!(root, graph, stale: stale)
|
package/plugins/devflow-core/skills/workflow-orchestrator/scripts/runtime_context_collector.rb
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Repository-declared runtime context collector (P0).
|
|
5
|
+
# It reads manifest content from one immutable Git revision rather than the
|
|
6
|
+
# working tree, so every inventory entry and every absence claim is replayable.
|
|
7
|
+
require "digest"
|
|
8
|
+
require "json"
|
|
9
|
+
require "open3"
|
|
10
|
+
require "optparse"
|
|
11
|
+
require "rexml/document"
|
|
12
|
+
require "rexml/xpath"
|
|
13
|
+
require_relative "control_io"
|
|
14
|
+
|
|
15
|
+
module RuntimeContextCollector
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
HISTORY_DIR = File.join(".context", "runtime-context", "revisions")
|
|
18
|
+
POINTER = File.join(".context", "runtime-context.json")
|
|
19
|
+
EXTRACTORS = {
|
|
20
|
+
"package.json" => :extract_package_json,
|
|
21
|
+
"requirements.txt" => :extract_requirements_txt,
|
|
22
|
+
"pom.xml" => :extract_pom_xml
|
|
23
|
+
}.freeze
|
|
24
|
+
EXCLUDED_ROOTS = %w[node_modules vendor dist build .git].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def safe_version(value)
|
|
29
|
+
text = value.to_s.strip
|
|
30
|
+
# A dependency version is useful evidence, but a direct URL can carry an
|
|
31
|
+
# access token. P0 does not consume versions, so redact unsafe forms.
|
|
32
|
+
return nil if text.empty? || text.match?(%r{[:/@]})
|
|
33
|
+
|
|
34
|
+
text
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract_package_json(content)
|
|
38
|
+
parsed = JSON.parse(content)
|
|
39
|
+
deps = parsed["dependencies"].is_a?(Hash) ? parsed["dependencies"] : {}
|
|
40
|
+
deps.map { |name, version| { "subject" => name.to_s.downcase.strip, "version" => safe_version(version) } }
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
raise "parse_error"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def extract_requirements_txt(content)
|
|
46
|
+
content.lines.map(&:strip).reject { |line| line.empty? || line.start_with?("#", "-e", "--", "-r", "-f") }.map do |line|
|
|
47
|
+
name = line.split(/[<>=!\[\s]/, 2).first.to_s.downcase.strip
|
|
48
|
+
version = line.match(/\A[^<>=!\[\s]+\s*(?:==|>=|<=|~=|>|<)\s*([^\s;]+)\z/)&.[](1)
|
|
49
|
+
{ "subject" => name, "version" => safe_version(version) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extract_pom_xml(content)
|
|
54
|
+
document = REXML::Document.new(content)
|
|
55
|
+
REXML::XPath.match(document, "//dependency").map do |dependency|
|
|
56
|
+
group = dependency.elements["groupId"]&.text.to_s.strip
|
|
57
|
+
artifact = dependency.elements["artifactId"]&.text.to_s.strip
|
|
58
|
+
next if group.empty? || artifact.empty?
|
|
59
|
+
|
|
60
|
+
{ "subject" => "#{group}:#{artifact}".downcase, "version" => safe_version(dependency.elements["version"]&.text) }
|
|
61
|
+
end.compact
|
|
62
|
+
rescue REXML::ParseException
|
|
63
|
+
raise "parse_error"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def git_capture(root, *args)
|
|
67
|
+
stdout, stderr, status = Open3.capture3("git", "-C", root, *args)
|
|
68
|
+
[stdout, stderr, status.success?]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def git_revision(root)
|
|
72
|
+
output, _error, success = git_capture(root, "rev-parse", "HEAD")
|
|
73
|
+
success ? output.strip : ""
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def excluded_path?(path)
|
|
77
|
+
path.split("/").any? { |segment| EXCLUDED_ROOTS.include?(segment) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def git_file_list(root, revision)
|
|
81
|
+
output, error, success = git_capture(root, "ls-tree", "-r", "--name-only", revision)
|
|
82
|
+
raise "git_tree_unavailable: #{error.strip}" unless success
|
|
83
|
+
|
|
84
|
+
supported = output.lines.map(&:strip).select { |path| EXTRACTORS.key?(File.basename(path)) }.sort
|
|
85
|
+
included, excluded = supported.partition { |path| !excluded_path?(path) }
|
|
86
|
+
[included, excluded]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def git_file_content(root, revision, path)
|
|
90
|
+
output, error, success = git_capture(root, "show", "#{revision}:#{path}")
|
|
91
|
+
raise "git_show_failed: #{error.strip}" unless success
|
|
92
|
+
|
|
93
|
+
output
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Enumerates every supported manifest in the committed tree. Files not
|
|
97
|
+
# matching the explicit P0 allowlist are outside the declared collector
|
|
98
|
+
# scope; every matching file has a scanned or failed entry.
|
|
99
|
+
def discover_and_extract(root, revision)
|
|
100
|
+
files = []
|
|
101
|
+
inventory = {}
|
|
102
|
+
paths, excluded_paths = git_file_list(root, revision)
|
|
103
|
+
excluded_paths.each do |path|
|
|
104
|
+
files << { "path" => path, "status" => "excluded", "reason" => "third_party_or_build_output" }
|
|
105
|
+
end
|
|
106
|
+
paths.each do |path|
|
|
107
|
+
extractor = EXTRACTORS.fetch(File.basename(path))
|
|
108
|
+
begin
|
|
109
|
+
content = git_file_content(root, revision, path)
|
|
110
|
+
entries = send(extractor, content)
|
|
111
|
+
evidence = {
|
|
112
|
+
"path" => path,
|
|
113
|
+
"git_revision" => revision,
|
|
114
|
+
"sha256" => Digest::SHA256.hexdigest(content),
|
|
115
|
+
"extractor" => extractor.to_s
|
|
116
|
+
}
|
|
117
|
+
entries.each do |entry|
|
|
118
|
+
subject = entry["subject"].to_s.downcase.strip
|
|
119
|
+
next if subject.empty?
|
|
120
|
+
|
|
121
|
+
inventory[subject] ||= {
|
|
122
|
+
"kind" => "runtime_dep", "subject" => subject,
|
|
123
|
+
"version" => entry["version"], "evidence" => evidence
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
files << { "path" => path, "status" => "scanned" }
|
|
127
|
+
rescue StandardError => error
|
|
128
|
+
files << { "path" => path, "status" => "failed", "error" => error.message }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
[files, inventory.values]
|
|
132
|
+
rescue StandardError => error
|
|
133
|
+
[[{ "path" => "<repository>", "status" => "failed", "error" => error.message }], []]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def current_revision(root)
|
|
137
|
+
pointer = File.join(root, POINTER)
|
|
138
|
+
return 0 unless File.exist?(pointer)
|
|
139
|
+
|
|
140
|
+
JSON.parse(File.read(pointer))["current_revision"].to_i
|
|
141
|
+
rescue JSON::ParserError
|
|
142
|
+
raise ArgumentError, "runtime-context pointer is invalid JSON: #{pointer}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def collect(root)
|
|
146
|
+
repository_revision = git_revision(root)
|
|
147
|
+
files, inventory =
|
|
148
|
+
if repository_revision.empty?
|
|
149
|
+
[[{ "path" => "<repository>", "status" => "failed", "error" => "git_revision_unavailable" }], []]
|
|
150
|
+
else
|
|
151
|
+
discover_and_extract(root, repository_revision)
|
|
152
|
+
end
|
|
153
|
+
coverage_status = files.any? { |file| file["status"] == "failed" } ? "incomplete" : "complete"
|
|
154
|
+
revision = current_revision(root) + 1
|
|
155
|
+
snapshot = {
|
|
156
|
+
"schema_version" => SCHEMA_VERSION,
|
|
157
|
+
"context_id" => "repo-default",
|
|
158
|
+
"snapshot_revision" => revision,
|
|
159
|
+
"scope" => "repository-default:package.json|requirements.txt|pom.xml",
|
|
160
|
+
"repository_revision" => repository_revision,
|
|
161
|
+
"discovery_hash" => Digest::SHA256.hexdigest(JSON.generate(files)),
|
|
162
|
+
"collector" => {
|
|
163
|
+
"version" => "git-manifest-allowlist-v1",
|
|
164
|
+
"discovery" => { "rules" => EXTRACTORS.keys.sort, "files" => files }
|
|
165
|
+
},
|
|
166
|
+
"inventory_hash" => Digest::SHA256.hexdigest(JSON.generate(inventory)),
|
|
167
|
+
"coverage_status" => coverage_status,
|
|
168
|
+
"inventory" => inventory
|
|
169
|
+
}
|
|
170
|
+
history = File.join(root, HISTORY_DIR, "#{revision}.json")
|
|
171
|
+
DevflowControlIO.atomic_create(history, JSON.pretty_generate(snapshot) + "\n")
|
|
172
|
+
DevflowControlIO.atomic_write(File.join(root, POINTER), JSON.pretty_generate({ "current_revision" => revision, "path" => history.sub("#{root}/", "") }) + "\n")
|
|
173
|
+
DevflowControlIO.append_audit(File.join(root, ".context", "runtime-context", "audit.jsonl"), {
|
|
174
|
+
"event" => "snapshot_collected", "snapshot_revision" => revision,
|
|
175
|
+
"repository_revision" => repository_revision, "coverage_status" => coverage_status
|
|
176
|
+
})
|
|
177
|
+
DevflowControlIO.register_control_artifact(root, {
|
|
178
|
+
"path" => history.sub("#{root}/", ""), "type" => "runtime_context_snapshot",
|
|
179
|
+
"produced_by" => "core", "snapshot_revision" => revision,
|
|
180
|
+
"coverage_status" => coverage_status, "repository_revision" => repository_revision
|
|
181
|
+
})
|
|
182
|
+
snapshot
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if __FILE__ == $PROGRAM_NAME
|
|
187
|
+
options = { json: false }
|
|
188
|
+
command = ARGV.shift
|
|
189
|
+
OptionParser.new do |o|
|
|
190
|
+
o.on("--json") { options[:json] = true }
|
|
191
|
+
end.parse!
|
|
192
|
+
root = Dir.pwd
|
|
193
|
+
lock = command == "collect" ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
194
|
+
begin
|
|
195
|
+
raise ArgumentError, "command must be collect or inspect" unless %w[collect inspect].include?(command)
|
|
196
|
+
result =
|
|
197
|
+
if command == "collect"
|
|
198
|
+
snapshot = RuntimeContextCollector.collect(root)
|
|
199
|
+
{ "ok" => true, "snapshot_revision" => snapshot["snapshot_revision"], "coverage_status" => snapshot["coverage_status"], "inventory_count" => snapshot["inventory"].length, "repository_revision" => snapshot["repository_revision"] }
|
|
200
|
+
else
|
|
201
|
+
pointer = File.join(root, RuntimeContextCollector::POINTER)
|
|
202
|
+
{ "ok" => true, "current_revision" => RuntimeContextCollector.current_revision(root), "pointer_exists" => File.exist?(pointer) }
|
|
203
|
+
end
|
|
204
|
+
rescue StandardError => error
|
|
205
|
+
result = { "ok" => false, "reason" => "runtime_context_collector_failed", "message" => error.message }
|
|
206
|
+
ensure
|
|
207
|
+
DevflowControlIO.release_lock(lock)
|
|
208
|
+
end
|
|
209
|
+
puts JSON.pretty_generate(result)
|
|
210
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Field-level staleness check (P1). A downstream consumer pins the exact
|
|
5
|
+
# evidence paths it consumed plus the snapshot revision it read; this checker
|
|
6
|
+
# compares evidence sha256 across revisions and reports only the changed
|
|
7
|
+
# paths — not a whole-snapshot invalidation.
|
|
8
|
+
require "json"
|
|
9
|
+
require "optparse"
|
|
10
|
+
require_relative "runtime_context_collector"
|
|
11
|
+
|
|
12
|
+
module StaleCheck
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def evidence_map(snapshot)
|
|
16
|
+
map = {}
|
|
17
|
+
Array(snapshot["inventory"]).each do |item|
|
|
18
|
+
evidence = item["evidence"]
|
|
19
|
+
next unless evidence.is_a?(Hash) && evidence["path"]
|
|
20
|
+
map[evidence["path"]] = evidence["sha256"]
|
|
21
|
+
end
|
|
22
|
+
map
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def changed_paths(old_snapshot, new_snapshot, evidence_paths)
|
|
26
|
+
old_map = evidence_map(old_snapshot)
|
|
27
|
+
new_map = evidence_map(new_snapshot)
|
|
28
|
+
Array(evidence_paths).select { |path| old_map[path] != new_map[path] }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if __FILE__ == $PROGRAM_NAME
|
|
33
|
+
options = { consumed_revision: nil, evidence_paths: nil }
|
|
34
|
+
command = ARGV.shift
|
|
35
|
+
OptionParser.new do |o|
|
|
36
|
+
o.on("--consumed-revision N", Integer) { |v| options[:consumed_revision] = v }
|
|
37
|
+
o.on("--evidence-paths JSON") { |v| options[:evidence_paths] = v }
|
|
38
|
+
o.on("--json") { options[:json] = true }
|
|
39
|
+
end.parse!
|
|
40
|
+
root = Dir.pwd
|
|
41
|
+
begin
|
|
42
|
+
raise ArgumentError, "command must be check" unless command == "check"
|
|
43
|
+
raise ArgumentError, "--consumed-revision is required" if options[:consumed_revision].nil?
|
|
44
|
+
current = RuntimeContextCollector.current_revision(root)
|
|
45
|
+
old_path = File.join(root, RuntimeContextCollector::HISTORY_DIR, "#{options[:consumed_revision]}.json")
|
|
46
|
+
new_path = File.join(root, RuntimeContextCollector::HISTORY_DIR, "#{current}.json")
|
|
47
|
+
raise ArgumentError, "consumed snapshot not found" unless File.exist?(old_path)
|
|
48
|
+
old = JSON.parse(File.read(old_path))
|
|
49
|
+
new = JSON.parse(File.read(new_path))
|
|
50
|
+
paths = JSON.parse(options[:evidence_paths].to_s)
|
|
51
|
+
changed = StaleCheck.changed_paths(old, new, paths)
|
|
52
|
+
{ "ok" => true, "consumed_revision" => options[:consumed_revision], "current_revision" => current, "changed_paths" => changed }
|
|
53
|
+
rescue StandardError => error
|
|
54
|
+
{ "ok" => false, "reason" => "stale_check_failed", "message" => error.message }
|
|
55
|
+
end.tap { |r| puts JSON.pretty_generate(r) }
|
|
56
|
+
end
|