@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.
Files changed (24) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +297 -14
  4. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +3 -2
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_assessment.rb +458 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +256 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +155 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +176 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +46 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +187 -12
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/runtime_context_collector.rb +210 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/stale_check.rb +56 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/static_runtime_context_collector.rb +518 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +83 -1
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workload_constraint.rb +57 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +54 -2
  19. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -2
  20. package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +17 -1
  21. package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +37 -0
  22. package/plugins/devflow-design/skills/workflow-architecture-feasibility/agents/openai.yaml +4 -0
  23. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +7 -2
  24. package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -1
@@ -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
- SCHEMA_VERSION = 2
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) && [1, SCHEMA_VERSION].include?(parsed["schema_version"]) && parsed["feature"] == feature &&
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" => 2, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
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" => 2, "feature" => graph["feature"], "graph_revision" => graph["revision"],
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" => 2, "origin" => "core_requirement_graph", "graph_revision" => graph["revision"])
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" => 2, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
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)
@@ -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