@guanlin4924/devflow-pi 0.1.39 → 0.1.47

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 (53) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +728 -39
  3. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
  4. package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
  5. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
  6. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
  7. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
  8. package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +334 -30
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
  26. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
  27. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
  28. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
  29. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -0
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +84 -6
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
  43. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  44. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  45. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  46. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
  47. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
  48. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  49. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  50. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  51. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  52. package/src/constants.ts +1 -1
  53. package/src/runtime.ts +61 -24
@@ -9,6 +9,7 @@ require "json"
9
9
  require "optparse"
10
10
  require "pathname"
11
11
  require "set"
12
+ require "yaml"
12
13
  require_relative "control_io"
13
14
  require_relative "architecture_constraints"
14
15
 
@@ -54,6 +55,22 @@ module RequirementGraph
54
55
  File.dirname(path)
55
56
  end
56
57
 
58
+ def active_feature(root)
59
+ state_path = File.join(root, ".planning", "STATE.md")
60
+ return nil unless File.file?(state_path)
61
+ front = File.read(state_path).match(/\A---\n(.*?)\n---/m)&.[](1)
62
+ return nil unless front
63
+ YAML.safe_load(front, permitted_classes: [], aliases: false)["current_feature"]
64
+ rescue Psych::Exception
65
+ nil
66
+ end
67
+
68
+ def assert_active_feature!(root, feature)
69
+ current = active_feature(root)
70
+ return if current.nil? || current == feature
71
+ raise ArgumentError, "feature is not active: requested=#{feature}, current=#{current}; bootstrap the feature before writing its requirement graph"
72
+ end
73
+
57
74
  def empty_graph(feature)
58
75
  { "schema_version" => SCHEMA_VERSION, "feature" => feature, "revision" => 0,
59
76
  "nodes" => [], "edges" => [], "stale" => [], "views" => {}, "audit" => [] }
@@ -373,15 +390,76 @@ module RequirementGraph
373
390
  node["kind"] == "test_case" && node["status"] == "confirmed" && !Array(graph["stale"]).any? { |entry| entry.is_a?(Hash) && entry["node_id"] == node["id"] }
374
391
  end
375
392
 
393
+ TEST_PREREQUISITE_FIELDS = %w[acceptance_refs requirement_refs api_refs workload_refs risk_refs metric_refs read_model_refs].freeze
394
+ CP3_CONFIRMABLE_PREREQUISITE_KINDS = %w[source behavior requirement rule boundary acceptance].freeze
395
+
396
+ def test_prerequisite_nodes(graph)
397
+ by_id = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["id"].is_a?(String) }.to_h { |node| [node["id"], node] }
398
+ ids = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }.flat_map do |node|
399
+ data = node["data"].is_a?(Hash) ? node["data"] : {}
400
+ TEST_PREREQUISITE_FIELDS.flat_map { |field| Array(data[field]) }
401
+ end.compact.uniq
402
+
403
+ # A reviewed TC may point at a requirement whose own source/behavior facts
404
+ # were introduced by a legacy backfill. Include that small dependency
405
+ # closure so the user sees every fact that the CP-3 recovery will confirm.
406
+ queue = ids.dup
407
+ until queue.empty?
408
+ node = by_id[queue.shift]
409
+ next unless node && node["kind"] == "requirement"
410
+ data = node["data"].is_a?(Hash) ? node["data"] : {}
411
+ dependencies = Array(data["source_refs"]) + Array(data["behavior_ref"])
412
+ dependencies.each do |ref|
413
+ next unless ref.is_a?(String) && !ids.include?(ref)
414
+ ids << ref
415
+ queue << ref
416
+ end
417
+ end
418
+ ids.map { |id| by_id[id] }.compact
419
+ end
420
+
421
+ def candidate_test_prerequisites(graph)
422
+ test_prerequisite_nodes(graph).select { |node| node["status"] != "confirmed" }
423
+ end
424
+
425
+ def confirm_test_prerequisites!(graph, cp_record)
426
+ candidates = candidate_test_prerequisites(graph)
427
+ unsupported = candidates.reject { |node| CP3_CONFIRMABLE_PREREQUISITE_KINDS.include?(node["kind"]) }
428
+ if unsupported.any?
429
+ return {
430
+ "ok" => false,
431
+ "reason" => "test_prerequisite_requires_earlier_gate",
432
+ "errors" => unsupported.map { |node| "#{node["id"]}: #{node["kind"]} must be confirmed by its owning checkpoint" }
433
+ }
434
+ end
435
+
436
+ order = { "source" => 0, "behavior" => 1, "rule" => 2, "boundary" => 3, "requirement" => 4, "acceptance" => 5 }
437
+ confirmed = []
438
+ candidates.sort_by { |node| [order.fetch(node["kind"], 99), node["id"]] }.each do |node|
439
+ confirm(graph, node["id"], { "cp_record" => cp_record })
440
+ confirmed << node["id"]
441
+ end
442
+ { "ok" => true, "confirmed_prerequisite_ids" => confirmed }
443
+ rescue ArgumentError => error
444
+ { "ok" => false, "reason" => "test_prerequisite_confirmation_failed", "message" => error.message }
445
+ end
446
+
376
447
  # CP-3 is the single freeze point. Keep it here instead of in a Skill so the
377
448
  # graph revision, TC statuses, views and audit entry commit atomically under
378
449
  # the caller's control-plane lock.
379
- def freeze_test_cases!(root, graph, cp_record)
450
+ def freeze_test_cases!(root, graph, cp_record, confirm_prerequisites: false, persist: true)
380
451
  cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
381
452
  return { "ok" => false, "reason" => "test_suite_empty", "message" => "cannot freeze an empty TC suite" } if cases.empty?
382
453
  return { "ok" => false, "reason" => "test_suite_already_frozen", "message" => "confirmed test cases require a new candidate suite" } if cases.any? { |node| node["status"] == "confirmed" }
383
454
  return { "ok" => false, "reason" => "test_suite_graph_stale", "message" => "cannot freeze TC suite while the requirement graph has stale downstream nodes" } if Array(graph["stale"]).any?
384
455
 
456
+ prerequisite_confirmation = if confirm_prerequisites
457
+ confirm_test_prerequisites!(graph, cp_record)
458
+ else
459
+ { "ok" => true, "confirmed_prerequisite_ids" => [] }
460
+ end
461
+ return prerequisite_confirmation unless prerequisite_confirmation["ok"]
462
+
385
463
  checkers = checker_results(graph)
386
464
  errors = checkers.fetch("test-coverage-check", []).dup
387
465
  errors.concat(checkers.fetch("test-obligation-check", []))
@@ -395,12 +473,26 @@ module RequirementGraph
395
473
  clear_stale_for!(graph, node["id"])
396
474
  end
397
475
  graph["revision"] += 1
476
+ event = {
477
+ "event" => "test_suite_freeze",
478
+ "node_ids" => cases.map { |node| node["id"] },
479
+ "confirmed_prerequisite_ids" => prerequisite_confirmation["confirmed_prerequisite_ids"],
480
+ "cp_record" => cp_record
481
+ }
482
+ persist_test_suite_freeze!(root, graph, event) if persist
483
+ path, = graph_path(root, graph["feature"])
484
+ { "ok" => true, "path" => path.sub("#{File.realpath(root)}/", ""), "graph_revision" => graph["revision"],
485
+ "frozen_case_ids" => cases.map { |node| node["id"] },
486
+ "confirmed_prerequisite_ids" => prerequisite_confirmation["confirmed_prerequisite_ids"],
487
+ "tests_view" => persist ? graph.dig("views", "tests") : view_payload(graph, "tests"),
488
+ "audit_event" => event }
489
+ end
490
+
491
+ def persist_test_suite_freeze!(root, graph, event)
398
492
  write_views!(root, graph, stale: false)
399
493
  path, = graph_path(root, graph["feature"])
400
- append_audit!(root, graph, { "event" => "test_suite_freeze", "node_ids" => cases.map { |node| node["id"] }, "cp_record" => cp_record })
494
+ append_audit!(root, graph, event)
401
495
  DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
402
- { "ok" => true, "path" => path.sub("#{File.realpath(root)}/", ""), "graph_revision" => graph["revision"],
403
- "frozen_case_ids" => cases.map { |node| node["id"] }, "tests_view" => graph.dig("views", "tests") }
404
496
  end
405
497
 
406
498
  # The matrix compiler is Core-owned and deterministic; this importer is the
@@ -854,6 +946,33 @@ module RequirementGraph
854
946
  results
855
947
  end
856
948
 
949
+ # Requirement construction deliberately precedes acceptance-test design.
950
+ # These checkers describe obligations that must be materialized later; they
951
+ # are diagnostics, not baseline blockers. Keep them explicit in the output
952
+ # so callers cannot mistake a deferred obligation for a current failure.
953
+ DEFERRED_CHECKERS = %w[test-coverage-check test-obligation-check].freeze
954
+
955
+ def validation_report(graph)
956
+ checkers = checker_results(graph)
957
+ blocking = []
958
+ checkers.each do |id, issues|
959
+ next if DEFERRED_CHECKERS.include?(id)
960
+ blocking.concat(issues)
961
+ end
962
+ Array(graph["nodes"]).each do |node|
963
+ next unless node.is_a?(Hash) && high_risk_by_structure?(node)
964
+ blocking << "#{node["id"]}: derived high-risk node must be confirmed and provable" unless node["status"] == "confirmed"
965
+ end
966
+ {
967
+ "blocking_errors" => blocking.uniq,
968
+ "deferred_checkers" => DEFERRED_CHECKERS.to_h { |id| [id, Array(checkers[id])] }
969
+ }
970
+ end
971
+
972
+ def candidate_validation_errors(graph)
973
+ validation_report(graph).fetch("blocking_errors")
974
+ end
975
+
857
976
  def validate(graph)
858
977
  errors = []
859
978
  ids = []
@@ -872,11 +991,7 @@ module RequirementGraph
872
991
  # Test coverage becomes enforced only at acceptance-test-design and later
873
992
  # workflow steps. Baseline construction itself must remain valid before
874
993
  # any derived TC candidates exist.
875
- checker_results(graph).reject { |id, _| %w[test-coverage-check test-obligation-check].include?(id) }.each_value { |issues| errors.concat(issues) }
876
- Array(graph["nodes"]).each do |node|
877
- next unless node.is_a?(Hash) && high_risk_by_structure?(node)
878
- errors << "#{node["id"]}: derived high-risk node must be confirmed and provable" unless node["status"] == "confirmed"
879
- end
994
+ errors.concat(validation_report(graph).fetch("blocking_errors"))
880
995
  errors.uniq
881
996
  end
882
997
 
@@ -975,15 +1090,23 @@ if __FILE__ == $PROGRAM_NAME
975
1090
  opts.on("--json") { options[:json] = true }
976
1091
  end.parse!
977
1092
  root = Dir.pwd
1093
+ mutating_command = (%w[propose import-test-cases confirm].include?(command) && options[:write]) || (command == "context" && options[:write])
978
1094
  lock = %w[propose import-test-cases context confirm].include?(command) && options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
979
1095
  begin
1096
+ RequirementGraph.assert_active_feature!(root, options[:feature]) if mutating_command
980
1097
  graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
981
1098
  result = case command
982
1099
  when "inspect"
983
- { "ok" => true, "path" => relative, "graph" => graph, "errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph) }
1100
+ report = RequirementGraph.validation_report(graph)
1101
+ { "ok" => report.fetch("blocking_errors").empty?, "path" => relative, "graph" => graph,
1102
+ "errors" => report.fetch("blocking_errors"), "checkers" => RequirementGraph.checker_results(graph),
1103
+ "deferred_checkers" => report.fetch("deferred_checkers") }
984
1104
  when "validate"
985
- errors = RequirementGraph.validate(graph)
986
- { "ok" => errors.empty?, "path" => relative, "revision" => graph["revision"], "errors" => errors, "checkers" => RequirementGraph.checker_results(graph), "stale" => graph["stale"] }
1105
+ report = RequirementGraph.validation_report(graph)
1106
+ errors = report.fetch("blocking_errors")
1107
+ { "ok" => errors.empty?, "path" => relative, "revision" => graph["revision"], "errors" => errors,
1108
+ "checkers" => RequirementGraph.checker_results(graph), "deferred_checkers" => report.fetch("deferred_checkers"),
1109
+ "stale" => graph["stale"] }
987
1110
  when "context"
988
1111
  if options[:read]
989
1112
  pkg = RequirementGraph.load_context_package(root, graph["feature"], options[:slice_id])
@@ -1019,7 +1142,8 @@ if __FILE__ == $PROGRAM_NAME
1019
1142
  RequirementGraph.append_audit!(root, candidate, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
1020
1143
  DevflowControlIO.atomic_write(path, JSON.pretty_generate(candidate) + "\n")
1021
1144
  end
1022
- { "ok" => true, "path" => relative, "written" => options[:write], "revision" => options[:write] ? candidate["revision"] : graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(candidate), "checkers" => RequirementGraph.checker_results(candidate), "views" => candidate["views"] }
1145
+ report = RequirementGraph.validation_report(candidate)
1146
+ { "ok" => true, "path" => relative, "written" => options[:write], "revision" => options[:write] ? candidate["revision"] : graph["revision"], "applied" => applied, "validation_errors" => report.fetch("blocking_errors"), "checkers" => RequirementGraph.checker_results(candidate), "deferred_checkers" => report.fetch("deferred_checkers"), "views" => candidate["views"] }
1023
1147
  end
1024
1148
  when "import-test-cases"
1025
1149
  raise ArgumentError, "--write is required" unless options[:write]
@@ -1045,7 +1169,8 @@ if __FILE__ == $PROGRAM_NAME
1045
1169
  RequirementGraph.write_views!(root, graph, stale: stale)
1046
1170
  RequirementGraph.append_audit!(root, graph, { "event" => "graph_confirm", "node_id" => options[:node_id] })
1047
1171
  DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
1048
- { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
1172
+ report = RequirementGraph.validation_report(graph)
1173
+ { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => report.fetch("blocking_errors"), "checkers" => RequirementGraph.checker_results(graph), "deferred_checkers" => report.fetch("deferred_checkers"), "views" => graph["views"] }
1049
1174
  end
1050
1175
  else
1051
1176
  { "ok" => false, "reason" => "invalid_command", "message" => "command must be propose, import-test-cases, inspect, validate, context, or confirm" }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Core-owned, idempotent requirement-graph backfill. Renumbers existing node
5
+ # IDs (and every in-graph string reference to them), appends seed nodes/edges,
6
+ # then regenerates all derived views through the same writer the graph normally
7
+ # uses. It never hand-edits JSON: every mutation happens under the project lock
8
+ # and is persisted via DevflowControlIO.atomic_write.
9
+ require "json"
10
+ require "optparse"
11
+ require "set"
12
+ require_relative "requirement_graph"
13
+ require_relative "control_io"
14
+
15
+ options = { root: ".", feature: nil, seed: nil, write: false, json: false }
16
+ OptionParser.new do |opts|
17
+ opts.banner = "Usage: requirement_graph_backfill.rb --feature NAME --seed PATH [--write]"
18
+ opts.on("--root PATH") { |value| options[:root] = value }
19
+ opts.on("--feature NAME") { |value| options[:feature] = value }
20
+ opts.on("--seed PATH") { |value| options[:seed] = value }
21
+ opts.on("--write") { options[:write] = true }
22
+ opts.on("--json") { options[:json] = true }
23
+ end.parse!
24
+
25
+ def deep_replace(value, from, to)
26
+ case value
27
+ when Hash
28
+ value.each { |key, item| value[key] = deep_replace(item, from, to) }
29
+ value
30
+ when Array
31
+ value.map { |item| deep_replace(item, from, to) }
32
+ when String
33
+ value.include?(from) ? value.gsub(from, to) : value
34
+ else
35
+ value
36
+ end
37
+ end
38
+
39
+ root = File.realpath(options[:root])
40
+ lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
41
+
42
+ begin
43
+ raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
44
+ raise ArgumentError, "--seed is required" if options[:seed].to_s.empty?
45
+ seed_path = File.expand_path(options[:seed], root)
46
+ raise ArgumentError, "seed file is missing: #{options[:seed]}" unless File.file?(seed_path)
47
+
48
+ seed = JSON.parse(File.read(seed_path))
49
+ raise ArgumentError, "seed must be an object" unless seed.is_a?(Hash)
50
+ renumber = seed["renumber"] || {}
51
+ raise ArgumentError, "renumber must be an object" unless renumber.is_a?(Hash)
52
+ new_nodes = seed["nodes"] || []
53
+ new_edges = seed["edges"] || []
54
+ raise ArgumentError, "nodes must be an array" unless new_nodes.is_a?(Array)
55
+ raise ArgumentError, "edges must be an array" unless new_edges.is_a?(Array)
56
+
57
+ graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
58
+ existing_ids = graph["nodes"].map { |node| node["id"] }.to_set
59
+
60
+ # 1. Renumber: update the node id and every string reference (data, confirmation, edges).
61
+ renumber.each do |from, to|
62
+ raise ArgumentError, "renumber key is invalid: #{from}" unless from.is_a?(String) && from.match?(/\A[A-Z][A-Z0-9_]*-\d+\z/)
63
+ raise ArgumentError, "renumber target is invalid: #{to}" unless to.is_a?(String) && to.match?(/\A[A-Z][A-Z0-9_]*-\d+\z/)
64
+ raise ArgumentError, "renumber source node not found: #{from}" unless existing_ids.include?(from)
65
+ raise ArgumentError, "renumber target already exists: #{to}" if existing_ids.include?(to)
66
+ graph["nodes"].each do |node|
67
+ node["data"] = deep_replace(node["data"], from, to) if node["data"].is_a?(Hash)
68
+ node["confirmation"] = deep_replace(node["confirmation"], from, to) if node["confirmation"].is_a?(Hash)
69
+ node["id"] = to if node["id"] == from
70
+ end
71
+ graph["edges"].each do |edge|
72
+ edge["from"] = to if edge["from"] == from
73
+ edge["to"] = to if edge["to"] == from
74
+ end
75
+ existing_ids = graph["nodes"].map { |node| node["id"] }.to_set
76
+ end
77
+
78
+ # Fingerprints are content-addressed; recompute after the renumber mutates data.
79
+ graph["nodes"].each { |node| RequirementGraph.set_node_fingerprint!(node) }
80
+
81
+ # 2. Append seed nodes with explicit IDs (idempotency: refuse duplicates).
82
+ new_nodes.each do |node|
83
+ raise ArgumentError, "seed node must be an object" unless node.is_a?(Hash)
84
+ id = node["id"]
85
+ kind = node["kind"]
86
+ raise ArgumentError, "seed node requires id and kind" unless id.is_a?(String) && !id.empty? && kind.is_a?(String)
87
+ raise ArgumentError, "seed node kind is unsupported: #{kind}" unless RequirementGraph::KINDS.key?(kind)
88
+ raise ArgumentError, "seed node id already exists: #{id}" if existing_ids.include?(id)
89
+ node["status"] = "candidate" unless %w[candidate confirmed].include?(node["status"])
90
+ node["data"] = {} unless node["data"].is_a?(Hash)
91
+ RequirementGraph.set_node_fingerprint!(node)
92
+ graph["nodes"] << node
93
+ existing_ids << id
94
+ end
95
+
96
+ # 3. Append edges, deduping against the existing edge set.
97
+ graph["edges"] = graph["edges"].uniq { |edge| [edge["from"], edge["to"], edge["relation"]] }
98
+ edge_added = 0
99
+ new_edges.each do |edge|
100
+ raise ArgumentError, "seed edge must be an object" unless edge.is_a?(Hash)
101
+ %w[from to relation].each do |key|
102
+ raise ArgumentError, "seed edge requires #{key}" unless edge[key].is_a?(String) && !edge[key].empty?
103
+ end
104
+ raise ArgumentError, "seed edge from/to must reference known nodes" unless existing_ids.include?(edge["from"]) && existing_ids.include?(edge["to"])
105
+ next if graph["edges"].any? { |existing| existing["from"] == edge["from"] && existing["to"] == edge["to"] && existing["relation"] == edge["relation"] }
106
+
107
+ graph["edges"] << edge
108
+ edge_added += 1
109
+ end
110
+
111
+ # 4. Advance the graph revision once for the whole backfill.
112
+ graph["revision"] = graph["revision"].to_i + 1
113
+
114
+ # 5. Regenerate derived views and persist under the project lock.
115
+ if options[:write]
116
+ stale = Array(graph["stale"]).any?
117
+ RequirementGraph.write_views!(root, graph, stale: stale)
118
+ RequirementGraph.append_audit!(root, graph, {
119
+ "event" => "graph_backfill",
120
+ "renumber" => renumber,
121
+ "added_node_ids" => new_nodes.map { |node| node["id"] },
122
+ "added_edge_count" => edge_added
123
+ })
124
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
125
+ end
126
+
127
+ result = {
128
+ "ok" => true,
129
+ "written" => options[:write],
130
+ "path" => relative,
131
+ "revision" => graph["revision"],
132
+ "renumber" => renumber,
133
+ "added_node_ids" => new_nodes.map { |node| node["id"] },
134
+ "added_edge_count" => edge_added,
135
+ "validation_errors" => RequirementGraph.validate(graph)
136
+ }
137
+ puts JSON.pretty_generate(result)
138
+ rescue ArgumentError, JSON::ParserError => error
139
+ puts JSON.pretty_generate({ "ok" => false, "reason" => "requirement_graph_backfill_failed", "message" => error.message })
140
+ exit 1
141
+ ensure
142
+ DevflowControlIO.release_lock(lock)
143
+ end
@@ -13,6 +13,7 @@ require "securerandom"
13
13
  require "time"
14
14
  require "yaml"
15
15
  require_relative "control_io"
16
+ require_relative "parallel_run"
16
17
 
17
18
  DEFAULT_LEASE_SECONDS = 60
18
19
  options = { json: false, write: false, lease_seconds: DEFAULT_LEASE_SECONDS }
@@ -46,6 +47,15 @@ def write_state(path, state, previous)
46
47
  DevflowControlIO.atomic_write(path, "---\n#{front}---\n#{body}")
47
48
  end
48
49
 
50
+ def rebase_parallel_run(root, state, lock)
51
+ feature = state["current_feature"]
52
+ return if feature.to_s.empty?
53
+
54
+ ParallelRun.rebase_state_revision(
55
+ root: root, feature: feature, state_revision: state.fetch("revision"), lock: lock
56
+ )
57
+ end
58
+
49
59
  def valid_session_id?(value)
50
60
  value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:-]{0,255}\z/)
51
61
  end
@@ -151,6 +161,7 @@ result = catch(:transition_done) do
151
161
  }
152
162
  state["revision"] = revision + 1
153
163
  write_state(state_path, state, raw)
164
+ rebase_parallel_run(root, state, lock)
154
165
  end
155
166
  result = { "ok" => true, "written" => options[:write], "rollover" => options[:write] ? state["rollover"] : payload.merge("handoff_sha256" => digest), "revision" => options[:write] ? revision + 1 : revision }
156
167
  when "claim"
@@ -178,7 +189,7 @@ result = catch(:transition_done) do
178
189
  end
179
190
  if options[:write]
180
191
  rollover["status"] = "claimed"; rollover["target_session_id"] = options[:target_session_id]; rollover["claimed_at"] = iso_now; rollover["lease_expires_at"] = lease_expiry(options[:lease_seconds])
181
- state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
192
+ state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw); rebase_parallel_run(root, state, lock)
182
193
  end
183
194
  result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
184
195
  when "renew"
@@ -194,7 +205,7 @@ result = catch(:transition_done) do
194
205
  if options[:write]
195
206
  rollover["lease_expires_at"] = lease_expiry(options[:lease_seconds])
196
207
  rollover["last_renewed_at"] = iso_now
197
- state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
208
+ state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw); rebase_parallel_run(root, state, lock)
198
209
  end
199
210
  result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
200
211
  when "recover"
@@ -214,7 +225,7 @@ result = catch(:transition_done) do
214
225
  end
215
226
  if options[:write]
216
227
  rollover["status"] = "prepared"; rollover["target_session_id"] = nil; rollover["claimed_at"] = nil; rollover.delete("lease_expires_at"); rollover.delete("last_renewed_at")
217
- state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
228
+ state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw); rebase_parallel_run(root, state, lock)
218
229
  end
219
230
  result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
220
231
  when "consume"
@@ -228,7 +239,7 @@ result = catch(:transition_done) do
228
239
  end
229
240
  if options[:write]
230
241
  rollover["status"] = "consumed"; rollover["consumed_at"] = iso_now
231
- state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
242
+ state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw); rebase_parallel_run(root, state, lock)
232
243
  end
233
244
  result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
234
245
  else
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "date"
5
+ require "digest"
5
6
  require "fileutils"
6
7
  require "json"
7
8
  require "optparse"
@@ -109,6 +110,40 @@ def content_validity(root, path, checks)
109
110
  { "valid" => issues.empty?, "issues" => issues }
110
111
  end
111
112
 
113
+ def questions_registry_entry(root, state, existing_by_path)
114
+ feature = state.fetch("current_feature", "").to_s
115
+ path = ".planning/features/#{feature}/questions.yaml"
116
+ absolute, = DevflowControlIO.resolve_control_path(root, path, :artifact)
117
+ exists = File.exist?(absolute)
118
+ entry = {
119
+ "path" => path,
120
+ "type" => "wireframe-questions",
121
+ "workflow_step" => "wireframe-proposal",
122
+ "produced_by" => "wireframe Skill",
123
+ "status" => "candidate",
124
+ "exists" => exists,
125
+ "content_valid" => false,
126
+ "content_issues" => exists ? nil : ["file_missing"],
127
+ "questions_status" => exists ? "present" : "unavailable"
128
+ }
129
+ if exists
130
+ entry["fingerprint"] = "sha256:#{Digest::SHA256.file(absolute).hexdigest}"
131
+ begin
132
+ questions = load_yaml_file(absolute)
133
+ list = questions.is_a?(Hash) ? Array(questions["questions"]) : []
134
+ entry["unresolved_count"] = list.count do |question|
135
+ question.is_a?(Hash) && question["kind"] == "blocking" && question["status"] == "open"
136
+ end
137
+ entry["content_valid"] = true
138
+ entry.delete("content_issues")
139
+ rescue StandardError => error
140
+ entry["questions_status"] = "invalid"
141
+ entry["content_issues"] = ["yaml_parse_error:#{error.message}"]
142
+ end
143
+ end
144
+ entry
145
+ end
146
+
112
147
  artifacts = collect_outputs(workflow).map do |step, output|
113
148
  path = interpolate_path(output.fetch("path"), state)
114
149
  existing = existing_by_path[path] || {}
@@ -145,6 +180,15 @@ artifacts = collect_outputs(workflow).map do |step, output|
145
180
  }.reject { |_, value| value.nil? || value == [] }
146
181
  end
147
182
 
183
+ questions_path = ".planning/features/#{state.fetch("current_feature", "")}/questions.yaml"
184
+ questions_entry = questions_registry_entry(root, state, existing_by_path)
185
+ question_index = artifacts.index { |artifact| artifact["path"] == questions_path }
186
+ if question_index
187
+ artifacts[question_index] = questions_entry
188
+ else
189
+ artifacts << questions_entry
190
+ end
191
+
148
192
  registry = {
149
193
  "schema_version" => 1,
150
194
  "state_revision" => state.fetch("revision", 0),
@@ -154,7 +198,7 @@ registry = {
154
198
  "feature" => state["current_feature"],
155
199
  "phase" => state["current_phase"]
156
200
  },
157
- "status_values" => %w[planned generated approved completed passed pending_rerun stale failed],
201
+ "status_values" => %w[planned generated candidate approved completed passed pending_rerun stale failed],
158
202
  "artifacts" => artifacts
159
203
  }
160
204
 
@@ -9,9 +9,11 @@ require_relative "control_io"
9
9
  require_relative "phase_ownership"
10
10
  require_relative "requirement_graph"
11
11
  require_relative "architecture_feasibility"
12
+ require_relative "architecture_brief"
12
13
  require_relative "checker_registry"
13
14
  require_relative "requirement_coverage"
14
15
  require_relative "workflow_gate_contract"
16
+ require_relative "parallel_run"
15
17
 
16
18
  module WorkflowStateValidator
17
19
  REQUIRED_STATE_FIELDS = %w[
@@ -80,7 +82,11 @@ module WorkflowStateValidator
80
82
  tracked_ids = completed_ids + pending_ids
81
83
 
82
84
  validate_step_tracking(issues, state, step_ids, completed_ids, pending_ids, tracked_ids)
83
- validate_track_scope(issues, state, completed_ids)
85
+ if state["parallel_run_ref"]
86
+ validate_parallel_run(issues, root, state, workflow, step_by_id)
87
+ else
88
+ validate_track_scope(issues, state, completed_ids)
89
+ end
84
90
  validate_dependencies(issues, step_by_id, completed_ids)
85
91
  validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
86
92
  validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
@@ -269,6 +275,76 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
269
275
  end
270
276
  end
271
277
 
278
+ def validate_parallel_run(issues, root, state, workflow, step_by_id)
279
+ feature = state["current_feature"].to_s
280
+ run = ParallelRun.load(root, feature)
281
+ unless run
282
+ issues << issue("error", "parallel_run_missing", "parallel_run_ref does not resolve to a run record", feature: feature)
283
+ return
284
+ end
285
+ if run["run_id"].to_s != state["parallel_run_ref"].to_s
286
+ issues << issue("error", "parallel_run_ref_mismatch", "parallel_run_ref does not match the run record", reference: state["parallel_run_ref"], run_id: run["run_id"])
287
+ end
288
+ begin
289
+ bound_revision = Integer(run.fetch("bound_state_revision"))
290
+ issues << issue("error", "parallel_state_revision_mismatch", "parallel run is bound to a different STATE revision", state_revision: state["revision"], bound_state_revision: bound_revision) unless bound_revision == Integer(state.fetch("revision"))
291
+ rescue ArgumentError, TypeError, KeyError
292
+ issues << issue("error", "parallel_run_revision_invalid", "parallel run bound_state_revision is invalid")
293
+ end
294
+
295
+ window = Array(workflow["parallel_windows"]).find { |candidate| candidate.is_a?(Hash) && candidate["id"].to_s == run["workflow_window"].to_s }
296
+ unless window
297
+ issues << issue("error", "parallel_window_not_found", "parallel run references an unknown workflow window", window: run["workflow_window"])
298
+ return
299
+ end
300
+ join_step = window["join_step"].to_s
301
+ if state["current_step"].to_s != join_step
302
+ issues << issue("error", "parallel_current_step_invalid", "parallel state current_step must remain the window join step", current_step: state["current_step"], join_step: join_step)
303
+ end
304
+
305
+ branch_steps = window["branches"].is_a?(Hash) ? window["branches"].each_with_object({}) do |(track, steps), result|
306
+ Array(steps).each { |step| result[step.to_s] = track.to_s }
307
+ end : {}
308
+ seen_ids = {}
309
+ Array(run["branches"]).each do |branch|
310
+ unless branch.is_a?(Hash)
311
+ issues << issue("error", "parallel_branch_invalid", "parallel run branch must be a mapping")
312
+ next
313
+ end
314
+ branch_id = branch["branch_id"].to_s
315
+ step_id = branch["workflow_step"].to_s
316
+ track = branch["track"].to_s
317
+ issues << issue("error", "parallel_branch_id_missing", "parallel branch_id is required") if branch_id.empty?
318
+ issues << issue("error", "parallel_branch_duplicate_id", "parallel branch_id is duplicated", branch_id: branch_id) if seen_ids[branch_id]
319
+ seen_ids[branch_id] = true
320
+ issues << issue("error", "parallel_branch_step_missing", "parallel branch workflow_step is required", branch_id: branch_id) if step_id.empty?
321
+ issues << issue("error", "parallel_branch_step_unknown", "parallel branch step is not in the window", branch_id: branch_id, step: step_id) unless branch_steps.key?(step_id)
322
+ issues << issue("error", "parallel_branch_track_mismatch", "parallel branch track does not match the workflow window", branch_id: branch_id, track: track, expected_track: branch_steps[step_id]) if branch_steps[step_id] && track.sub(/\Atrack_/, "").upcase != branch_steps[step_id].sub(/\Atrack_/, "").upcase
323
+ begin
324
+ attempt = Integer(branch.fetch("attempt"))
325
+ branch_revision = Integer(branch.fetch("branch_revision"))
326
+ issues << issue("error", "parallel_branch_attempt_invalid", "parallel branch attempt must be non-negative", branch_id: branch_id) if attempt.negative?
327
+ issues << issue("error", "parallel_branch_revision_invalid", "parallel branch revision must be non-negative", branch_id: branch_id) if branch_revision.negative?
328
+ rescue ArgumentError, TypeError, KeyError
329
+ issues << issue("error", "parallel_branch_version_invalid", "parallel branch attempt and branch_revision are required integers", branch_id: branch_id)
330
+ end
331
+ expected_state_revision = branch["expected_state_revision"]
332
+ if expected_state_revision && expected_state_revision.to_i != state["revision"].to_i
333
+ issues << issue("error", "parallel_branch_state_revision_mismatch", "parallel branch is bound to a different STATE revision", branch_id: branch_id)
334
+ end
335
+ if step_id && state["completed_steps"].to_a.map(&:to_s).include?(step_id)
336
+ issues << issue("error", "parallel_step_completed_before_join", "parallel branch step must not enter completed_steps before join", branch_id: branch_id, step: step_id)
337
+ end
338
+ end
339
+ declared_steps = branch_steps.keys
340
+ actual_steps = Array(run["branches"]).select { |branch| branch.is_a?(Hash) }.map { |branch| branch["workflow_step"].to_s }
341
+ duplicate_steps = actual_steps.each_with_object(Hash.new(0)) { |step, counts| counts[step] += 1 }.select { |_, count| count > 1 }.keys
342
+ duplicate_steps.each { |step| issues << issue("error", "parallel_branch_step_duplicated", "parallel run contains duplicate workflow_step records", step: step) }
343
+ (declared_steps - actual_steps).each { |step| issues << issue("error", "parallel_branch_record_missing", "workflow branch step has no persisted branch record", step: step) }
344
+ rescue ArgumentError, TypeError, Psych::Exception => error
345
+ issues << issue("error", "parallel_run_invalid", "parallel run record is invalid", detail: error.message)
346
+ end
347
+
272
348
  def validate_checker_registry(issues, workflow)
273
349
  declared = workflow.dig("governance", "step_checkers")
274
350
  return unless declared.is_a?(Hash)
@@ -665,7 +741,15 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
665
741
 
666
742
  graph, = RequirementGraph.load_graph(root, feature)
667
743
  if brief["graph_revision"] != graph["revision"]
668
- issues << issue("error", "architecture_brief_graph_stale", "Architecture brief was derived from a stale graph revision", step: step["id"], brief_revision: brief["graph_revision"], graph_revision: graph["revision"])
744
+ brief_markdown_path = File.join(root, ".planning", "features", feature, "architecture-brief.md")
745
+ current_markdown = ArchitectureBrief.build(root, feature)[3]
746
+ normalize_revision = lambda do |markdown|
747
+ markdown.to_s.gsub(/需求图谱:schema v(\d+) \/ revision \d+/, '需求图谱:schema v\1 / revision <current>')
748
+ end
749
+ semantically_current = File.file?(brief_markdown_path) && normalize_revision.call(File.read(brief_markdown_path)) == normalize_revision.call(current_markdown)
750
+ unless semantically_current
751
+ issues << issue("error", "architecture_brief_graph_stale", "Architecture brief inputs changed after it was derived", step: step["id"], brief_revision: brief["graph_revision"], graph_revision: graph["revision"])
752
+ end
669
753
  end
670
754
  current_context = ArchitectureFeasibility.safe_static_context(root)
671
755
  current_context_revision = current_context["available"] ? current_context["snapshot_revision"] : nil