@guanlin4924/devflow-pi 0.1.24 → 0.1.26

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 (21) hide show
  1. package/README.md +8 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +252 -33
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +50 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +9 -1
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/api_test_matrix.py +2 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +19 -3
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +24 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +25 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +4 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +136 -15
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +159 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +36 -0
  15. package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +4 -3
  16. package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +4 -0
  17. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +2 -0
  18. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +13 -1
  19. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +2 -0
  20. package/src/constants.ts +1 -1
  21. package/src/runtime.ts +36 -1
@@ -78,6 +78,21 @@ module ArchitectureFeasibility
78
78
  data = node.fetch("data")
79
79
  if node["kind"] == "workload_shape"
80
80
  facts = data.fetch("facts")
81
+ case facts["archetype"]
82
+ when "analytics_query"
83
+ level = promote(level, "full")
84
+ triggers << { "code" => "ARC_ANALYTICS_QUERY", "node_id" => node["id"] }
85
+ when "analytics_projection"
86
+ level = promote(level, "full")
87
+ triggers << { "code" => "ARC_ANALYTICS_PROJECTION", "node_id" => node["id"] }
88
+ when "transactional_command"
89
+ level = promote(level, "lightweight")
90
+ triggers << { "code" => "ARC_TRANSACTIONAL_COMMAND", "node_id" => node["id"] }
91
+ end
92
+ if facts["consistency"] == "unknown"
93
+ level = promote(level, "lightweight")
94
+ unresolved << { "code" => "ARC_CONSISTENCY_UNKNOWN", "node_id" => node["id"] }
95
+ end
81
96
  case facts["data_effect"]
82
97
  when "migration"
83
98
  level = promote(level, "full")
@@ -121,10 +136,19 @@ module ArchitectureFeasibility
121
136
  end
122
137
  end
123
138
 
139
+ Array(graph["nodes"]).each do |node|
140
+ next unless node.is_a?(Hash) && node["kind"] == "read_model" && node["status"] == "confirmed"
141
+ data = node.fetch("data", {})
142
+ if data.dig("rebuild", "method") == "unknown" || data["late_data_policy"] == "unknown"
143
+ level = promote(level, "lightweight")
144
+ unresolved << { "code" => "ARC_READ_MODEL_RECOVERY_UNKNOWN", "node_id" => node["id"] }
145
+ end
146
+ end
147
+
124
148
  unless static_context["available"]
125
149
  unresolved << { "code" => static_context["code"], "detail" => static_context["detail"] }.compact
126
150
  end
127
- workload_errors = graph_errors.grep(/\A(?:WSH|WLC)-/)
151
+ workload_errors = graph_errors.grep(/\A(?:WSH|WLC|METRIC|RM)-/)
128
152
  unresolved << { "code" => "ARC_WORKLOAD_GRAPH_INVALID", "count" => workload_errors.length } if workload_errors.any?
129
153
 
130
154
  # P2 deliberately uses no numeric threshold. A numeric constraint is a
@@ -31,6 +31,10 @@ module DevflowControlIO
31
31
  when :workflow then normalized.match?(%r{\A\.planning/workflows/[A-Za-z0-9][A-Za-z0-9_-]*\.yaml\z})
32
32
  when :registry then normalized == ".planning/artifacts/index.yaml"
33
33
  when :artifact then normalized.start_with?(".planning/") || normalized.start_with?(".context/")
34
+ # Feature names are user-facing and may be Unicode. `cleanpath`
35
+ # and the parent-traversal check above own containment; this
36
+ # matcher only fixes the handoff namespace and immutable name.
37
+ when :handoff then normalized.match?(%r{\A\.planning/handoffs/[^/]+/ROL-[0-9a-f-]+\.json\z})
34
38
  else false
35
39
  end
36
40
  raise InvalidControlPath, "invalid #{kind} control path" unless allowed
@@ -13,7 +13,7 @@ require_relative "requirement_graph"
13
13
  POLICY = {
14
14
  "requirement-baseline" => { "checkers" => %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
15
15
  "api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
16
- "architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk] },
16
+ "architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk metric read_model] },
17
17
  "integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
18
18
  "acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
19
19
  "change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
@@ -13,16 +13,17 @@ require_relative "control_io"
13
13
  require_relative "architecture_constraints"
14
14
 
15
15
  module RequirementGraph
16
- # v4 adds derived TC verification nodes. Existing v1-v3
16
+ # v5 adds workload archetypes plus METRIC/RM nodes. Existing v1-v4
17
17
  # graphs are migrated in memory and re-written only by this Core writer.
18
- SCHEMA_VERSION = 4
19
- SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3, SCHEMA_VERSION].freeze
18
+ SCHEMA_VERSION = 5
19
+ SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3, 4, SCHEMA_VERSION].freeze
20
20
  ROOT = ".planning/requirements"
21
21
  KINDS = {
22
22
  "source" => "SRC", "code" => "CODE", "term" => "TERM", "object" => "OBJECT",
23
23
  "behavior" => "BEH", "requirement" => "REQ", "rule" => "BR", "boundary" => "BC",
24
24
  "slice" => "SL", "risk" => "RISK", "decision" => "DECISION", "acceptance" => "AC",
25
- "api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC", "test_case" => "TC"
25
+ "api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC", "metric" => "METRIC",
26
+ "read_model" => "RM", "test_case" => "TC"
26
27
  }.freeze
27
28
  VIEW_NAMES = %w[evidence definitions behavior-graph requirement-graph slices acceptance tests traceability].freeze
28
29
  FEATURE_INVALID = /[\\\/\x00-\x1F\x7F]/
@@ -176,7 +177,7 @@ module RequirementGraph
176
177
  raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
177
178
  data = normalize_data(delta["data"])
178
179
  node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
179
- candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
180
+ candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
180
181
  raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
181
182
  set_node_fingerprint!(node)
182
183
  graph["nodes"] << node
@@ -193,12 +194,12 @@ module RequirementGraph
193
194
  id = delta["id"]
194
195
  node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
195
196
  raise ArgumentError, "update requires a known node id" unless node
196
- if (%w[workload_shape workload_constraint test_case].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
197
- raise ArgumentError, "confirmed workload nodes and architecture decisions are immutable; confirmed test cases are also immutable; add a new candidate and relink consumers"
197
+ if (%w[workload_shape workload_constraint metric read_model test_case].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
198
+ raise ArgumentError, "confirmed workload, metric/read-model, and architecture decision nodes are immutable; confirmed test cases are also immutable; add a new candidate and relink consumers"
198
199
  end
199
200
  data = normalize_data(delta["data"])
200
201
  node["data"] = node.fetch("data", {}).merge(data)
201
- candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
202
+ candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
202
203
  raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
203
204
  set_node_fingerprint!(node)
204
205
  clear_stale_for!(graph, id)
@@ -221,10 +222,10 @@ module RequirementGraph
221
222
  unless evidence.is_a?(Hash) && (evidence["cp_record"] || evidence["evidence_ref"])
222
223
  raise ArgumentError, "confirm requires cp_record or evidence_ref"
223
224
  end
224
- if (%w[workload_shape workload_constraint].include?(node["kind"]) || architecture_control_node?(node)) && !confirmed_interactively
225
- raise ArgumentError, "workload and architecture-control confirmation requires --confirmed-interactively from the Core confirmation dialog"
225
+ if (%w[workload_shape workload_constraint metric read_model].include?(node["kind"]) || architecture_control_node?(node)) && !confirmed_interactively
226
+ raise ArgumentError, "workload and architecture-control confirmation requires --confirmed-interactively from the Core confirmation dialog; metric/read-model candidates use the same dialog"
226
227
  end
227
- confirmation_errors = workload_confirmation_errors(graph, node, evidence) + architecture_decision_confirmation_errors(graph, node) + architecture_risk_confirmation_errors(graph, node)
228
+ confirmation_errors = workload_confirmation_errors(graph, node, evidence) + metric_confirmation_errors(graph, node) + read_model_confirmation_errors(graph, node) + architecture_decision_confirmation_errors(graph, node) + architecture_risk_confirmation_errors(graph, node)
228
229
  raise ArgumentError, confirmation_errors.join("; ") if confirmation_errors.any?
229
230
  node["status"] = "confirmed"
230
231
  node["confirmation"] = { "origin" => "core", "evidence" => evidence }
@@ -271,8 +272,14 @@ module RequirementGraph
271
272
  end
272
273
  end
273
274
 
274
- TEST_CASE_TYPES = %w[api page permission boundary resilience migration performance].freeze
275
+ TEST_CASE_TYPES = %w[api page permission boundary resilience migration performance analytics freshness reconciliation].freeze
275
276
  TEST_AUTOMATION_MODES = %w[automated manual blocked].freeze
277
+ # A verification strategy says what would constitute trustworthy evidence.
278
+ # It is intentionally separate from automation_mode: a page test may be
279
+ # replayable yet initially manual, while an intermittent incident may be
280
+ # causally proven without a deterministic replay.
281
+ TEST_EVIDENCE_STRATEGIES = %w[replay causal observation].freeze
282
+ REPLAY_SCOPES = %w[component page api integration].freeze
276
283
 
277
284
  # TC nodes are derived verification intent. They deliberately store only
278
285
  # externally observable setup, operation and oracle; implementation paths,
@@ -282,7 +289,7 @@ module RequirementGraph
282
289
 
283
290
  data = node["data"].is_a?(Hash) ? node["data"] : {}
284
291
  errors = []
285
- %w[acceptance_refs requirement_refs test_type target preconditions steps oracle automation_mode].each do |field|
292
+ %w[acceptance_refs requirement_refs test_type target preconditions steps oracle automation_mode evidence_strategy].each do |field|
286
293
  errors << "#{node["id"]}: missing #{field}" unless present?(data[field])
287
294
  end
288
295
  errors << "#{node["id"]}: acceptance_refs must be a non-empty array" unless data["acceptance_refs"].is_a?(Array) && !data["acceptance_refs"].empty?
@@ -291,6 +298,25 @@ module RequirementGraph
291
298
  errors << "#{node["id"]}: steps must be a non-empty array" unless data["steps"].is_a?(Array) && !data["steps"].empty?
292
299
  errors << "#{node["id"]}: invalid test_type" unless TEST_CASE_TYPES.include?(data["test_type"])
293
300
  errors << "#{node["id"]}: invalid automation_mode" unless TEST_AUTOMATION_MODES.include?(data["automation_mode"])
301
+ errors << "#{node["id"]}: invalid evidence_strategy" unless TEST_EVIDENCE_STRATEGIES.include?(data["evidence_strategy"])
302
+ case data["evidence_strategy"]
303
+ when "replay"
304
+ errors << "#{node["id"]}: replay tests require replay_scope" unless REPLAY_SCOPES.include?(data["replay_scope"])
305
+ when "causal"
306
+ refs = data["causal_evidence_refs"]
307
+ errors << "#{node["id"]}: causal tests require non-empty causal_evidence_refs" unless refs.is_a?(Array) && !refs.empty?
308
+ Array(refs).each do |ref|
309
+ errors << "#{node["id"]}: causal evidence ref #{ref} must reference SRC/CODE" unless refs_known?(graph, [ref], %w[SRC CODE])
310
+ end
311
+ errors << "#{node["id"]}: causal tests require first_divergence" unless present?(data["first_divergence"])
312
+ when "observation"
313
+ plan = data["observation_plan"]
314
+ errors << "#{node["id"]}: observation tests require observation_plan" unless plan.is_a?(Hash)
315
+ %w[signal owner window alert_condition].each do |field|
316
+ errors << "#{node["id"]}: observation_plan requires #{field}" unless plan.is_a?(Hash) && present?(plan[field])
317
+ end
318
+ errors << "#{node["id"]}: observation tests must remain blocked until Core-observed evidence exists" unless data["automation_mode"] == "blocked"
319
+ end
294
320
  if data.key?("test_obligation_refs")
295
321
  errors << "#{node["id"]}: test_obligation_refs must be a non-empty string array when present" unless data["test_obligation_refs"].is_a?(Array) && !data["test_obligation_refs"].empty? && data["test_obligation_refs"].all? { |ref| ref.is_a?(String) && ref.match?(/\AOBL-[A-Z0-9-]+\z/) }
296
322
  end
@@ -322,7 +348,9 @@ module RequirementGraph
322
348
  {
323
349
  "api_refs" => %w[API],
324
350
  "workload_refs" => %w[WSH WLC],
325
- "risk_refs" => %w[RISK]
351
+ "risk_refs" => %w[RISK],
352
+ "metric_refs" => %w[METRIC],
353
+ "read_model_refs" => %w[RM]
326
354
  }.each do |field, prefixes|
327
355
  next unless data.key?(field)
328
356
 
@@ -504,6 +532,16 @@ module RequirementGraph
504
532
  errors << "#{node["id"]}: workload source #{ref} must link with shapes_workload" if require_edges && !incoming_edge?(graph, ref, node["id"], "shapes_workload")
505
533
  errors << "#{node["id"]}: workload source #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
506
534
  end
535
+ { "metric_refs" => %w[METRIC], "read_model_refs" => %w[RM] }.each do |field, prefixes|
536
+ next unless data.key?(field)
537
+ errors << "#{node["id"]}: #{field} must be a non-empty array when present" unless data[field].is_a?(Array) && !data[field].empty?
538
+ Array(data[field]).each do |ref|
539
+ errors << "#{node["id"]}: #{field} #{ref} must exist in the graph" unless refs_known?(graph, [ref], prefixes)
540
+ relation = field == "metric_refs" ? "defines_metric" : "uses_read_model"
541
+ errors << "#{node["id"]}: #{field} #{ref} must link with #{relation}" if require_edges && !incoming_edge?(graph, ref, node["id"], relation)
542
+ errors << "#{node["id"]}: #{field} #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
543
+ end
544
+ end
507
545
  else
508
546
  workload_ref = data["workload_ref"]
509
547
  errors << "#{node["id"]}: workload_ref #{workload_ref} must exist in the graph" unless refs_known?(graph, [workload_ref], %w[BEH SL API])
@@ -528,6 +566,74 @@ module RequirementGraph
528
566
  errors
529
567
  end
530
568
 
569
+ # A metric is a business contract, not a free-text label on a dashboard.
570
+ # It deliberately has no status/ID input: those remain Core-owned.
571
+ def metric_node_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
572
+ return [] unless node["kind"] == "metric"
573
+
574
+ data = node.fetch("data", {})
575
+ errors = []
576
+ %w[name grain measure inclusion_rule_refs exclusion_rule_refs dimensions time_basis timezone correction_policy owner].each do |field|
577
+ errors << "#{node["id"]}: metric missing #{field}" unless present?(data[field])
578
+ end
579
+ %w[inclusion_rule_refs exclusion_rule_refs dimensions].each do |field|
580
+ errors << "#{node["id"]}: #{field} must be a non-empty array" unless data[field].is_a?(Array) && !data[field].empty?
581
+ end
582
+ Array(data["inclusion_rule_refs"]) .concat(Array(data["exclusion_rule_refs"])).each do |ref|
583
+ errors << "#{node["id"]}: metric rule #{ref} must reference BR" unless refs_known?(graph, [ref], %w[BR])
584
+ errors << "#{node["id"]}: metric rule #{ref} must link with defines_metric" if require_edges && !incoming_edge?(graph, ref, node["id"], "defines_metric")
585
+ errors << "#{node["id"]}: metric rule #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
586
+ end
587
+ errors.uniq
588
+ end
589
+
590
+ def metric_confirmation_errors(graph, node)
591
+ metric_node_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
592
+ end
593
+
594
+ READ_MODEL_MODES = %w[direct_query materialized_view scheduled_aggregate event_projection warehouse].freeze
595
+ REBUILD_METHODS = %w[replay_from_fact full_recompute unknown].freeze
596
+ LATE_DATA_POLICIES = %w[recompute_window correction_event reject unknown].freeze
597
+
598
+ def read_model_node_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
599
+ return [] unless node["kind"] == "read_model"
600
+
601
+ data = node.fetch("data", {})
602
+ errors = []
603
+ %w[source_of_truth_refs serves metric_contract_refs projection_mode rebuild late_data_policy reconciliation owner].each do |field|
604
+ errors << "#{node["id"]}: read model missing #{field}" unless present?(data[field])
605
+ end
606
+ %w[source_of_truth_refs serves metric_contract_refs].each do |field|
607
+ errors << "#{node["id"]}: #{field} must be a non-empty array" unless data[field].is_a?(Array) && !data[field].empty?
608
+ end
609
+ errors << "#{node["id"]}: invalid projection_mode" unless READ_MODEL_MODES.include?(data["projection_mode"])
610
+ errors << "#{node["id"]}: rebuild must be an object" unless data["rebuild"].is_a?(Hash)
611
+ errors << "#{node["id"]}: invalid rebuild.method" unless REBUILD_METHODS.include?(data.dig("rebuild", "method"))
612
+ errors << "#{node["id"]}: invalid late_data_policy" unless LATE_DATA_POLICIES.include?(data["late_data_policy"])
613
+ errors << "#{node["id"]}: reconciliation must be an object" unless data["reconciliation"].is_a?(Hash)
614
+ %w[method frequency].each { |field| errors << "#{node["id"]}: reconciliation missing #{field}" unless present?(data.dig("reconciliation", field)) }
615
+ Array(data["source_of_truth_refs"]).each do |ref|
616
+ errors << "#{node["id"]}: source #{ref} must reference BEH/OBJECT" unless refs_known?(graph, [ref], %w[BEH OBJECT])
617
+ errors << "#{node["id"]}: source #{ref} must link with derives_read_model" if require_edges && !incoming_edge?(graph, ref, node["id"], "derives_read_model")
618
+ errors << "#{node["id"]}: source #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
619
+ end
620
+ Array(data["serves"]).each do |ref|
621
+ errors << "#{node["id"]}: serves #{ref} must reference API" unless refs_known?(graph, [ref], %w[API])
622
+ errors << "#{node["id"]}: serves #{ref} must link with serves_query" if require_edges && !incoming_edge?(graph, node["id"], ref, "serves_query")
623
+ errors << "#{node["id"]}: serves #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
624
+ end
625
+ Array(data["metric_contract_refs"]).each do |ref|
626
+ errors << "#{node["id"]}: metric #{ref} must reference METRIC" unless refs_known?(graph, [ref], %w[METRIC])
627
+ errors << "#{node["id"]}: metric #{ref} must link with defines_read_model" if require_edges && !incoming_edge?(graph, ref, node["id"], "defines_read_model")
628
+ errors << "#{node["id"]}: metric #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
629
+ end
630
+ errors.uniq
631
+ end
632
+
633
+ def read_model_confirmation_errors(graph, node)
634
+ read_model_node_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
635
+ end
636
+
531
637
  def architecture_option_decision?(node)
532
638
  node["kind"] == "decision" && node.dig("data", "decision_type") == "architecture_option_comparison"
533
639
  end
@@ -666,6 +772,14 @@ module RequirementGraph
666
772
  results["requirement-graph-check"].concat(
667
773
  workload_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
668
774
  )
775
+ when "metric"
776
+ results["requirement-graph-check"].concat(
777
+ metric_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
778
+ )
779
+ when "read_model"
780
+ results["requirement-graph-check"].concat(
781
+ read_model_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
782
+ )
669
783
  when "acceptance"
670
784
  required(results["acceptance-coverage-check"], node, %w[requirement_ref verification_types])
671
785
  types = Array(node.dig("data", "verification_types"))
@@ -705,6 +819,13 @@ module RequirementGraph
705
819
  if node["kind"] == "workload_shape" && node.dig("data", "facts", "data_effect") == "migration"
706
820
  results["test-obligation-check"] << "#{node["id"]}: migration workload requires a migration TC with workload_refs" unless covered_by.call("migration", "workload_refs", node["id"])
707
821
  end
822
+ if node["kind"] == "workload_shape" && %w[analytics_query analytics_projection].include?(node.dig("data", "facts", "archetype"))
823
+ results["test-obligation-check"] << "#{node["id"]}: analytics workload requires an analytics TC with workload_refs" unless covered_by.call("analytics", "workload_refs", node["id"])
824
+ end
825
+ when "metric"
826
+ results["test-obligation-check"] << "#{node["id"]}: metric requires a reconciliation TC with metric_refs" unless covered_by.call("reconciliation", "metric_refs", node["id"])
827
+ when "read_model"
828
+ results["test-obligation-check"] << "#{node["id"]}: read model requires a freshness TC with read_model_refs" unless covered_by.call("freshness", "read_model_refs", node["id"])
708
829
  when "risk"
709
830
  if architecture_option_risk?(node)
710
831
  results["test-obligation-check"] << "#{node["id"]}: architecture risk requires a resilience TC with risk_refs" unless covered_by.call("resilience", "risk_refs", node["id"])
@@ -760,7 +881,7 @@ module RequirementGraph
760
881
  when "evidence" then %w[source code]
761
882
  when "definitions" then %w[term object rule boundary]
762
883
  when "behavior-graph" then %w[behavior]
763
- when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint test_case]
884
+ when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint metric read_model test_case]
764
885
  when "slices" then %w[slice]
765
886
  when "acceptance" then %w[acceptance risk decision]
766
887
  when "tests" then %w[test_case]
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Controlled, content-addressed session handoff state. This script is the only
5
+ # writer for STATE.rollover; callers must hold no assumptions about a
6
+ # filesystem "latest" handoff. STATE is the authority and immutable handoff
7
+ # JSON is the evidence it points to.
8
+
9
+ require "digest"
10
+ require "json"
11
+ require "optparse"
12
+ require "securerandom"
13
+ require "time"
14
+ require "yaml"
15
+ require_relative "control_io"
16
+
17
+ options = { json: false, write: false }
18
+ parser = OptionParser.new do |opts|
19
+ opts.banner = "Usage: session_rollover.rb prepare|claim|consume|inspect [options]"
20
+ opts.on("--write", "Apply the requested transition") { options[:write] = true }
21
+ opts.on("--json", "Print JSON") { options[:json] = true }
22
+ opts.on("--expected-revision N", Integer, "STATE revision compare-and-swap") { |v| options[:expected_revision] = v }
23
+ opts.on("--source-session-id ID", "Source OMP session id") { |v| options[:source_session_id] = v }
24
+ opts.on("--target-session-id ID", "Target OMP session id") { |v| options[:target_session_id] = v }
25
+ opts.on("--reason TEXT", "Auditable rollover reason") { |v| options[:reason] = v }
26
+ end
27
+ action = ARGV.shift
28
+ parser.parse!
29
+
30
+ def fail_result(reason, details = {})
31
+ { "ok" => false, "reason" => reason }.merge(details)
32
+ end
33
+
34
+ def parse_state(path)
35
+ raw = File.read(path)
36
+ match = raw.match(/\A---\n(.*?)\n---\n/m)
37
+ raise "STATE.md is missing YAML front matter" unless match
38
+ [YAML.safe_load(match[1], permitted_classes: [], aliases: false), raw]
39
+ end
40
+
41
+ def write_state(path, state, previous)
42
+ body = previous.sub(/\A---\n.*?\n---\n/m, "")
43
+ front = state.to_yaml.sub(/\A---\n/, "")
44
+ DevflowControlIO.atomic_write(path, "---\n#{front}---\n#{body}")
45
+ end
46
+
47
+ def valid_session_id?(value)
48
+ value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:-]{0,255}\z/)
49
+ end
50
+
51
+ def iso_now
52
+ Time.now.utc.iso8601(3)
53
+ end
54
+
55
+ root = Dir.pwd
56
+ state_path, = DevflowControlIO.resolve_control_path(root, ".planning/STATE.md", :state)
57
+ result = catch(:transition_done) do
58
+ lock = DevflowControlIO.acquire_project_lock(root)
59
+ begin
60
+ unless File.exist?(state_path)
61
+ result = fail_result("missing_state")
62
+ throw :transition_done, result
63
+ end
64
+ state, raw = parse_state(state_path)
65
+ revision = Integer(state.fetch("revision", 0))
66
+ if !options[:expected_revision].nil? && revision != options[:expected_revision]
67
+ result = fail_result("stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => revision)
68
+ throw :transition_done, result
69
+ end
70
+ rollover = state["rollover"]
71
+
72
+ case action
73
+ when "inspect"
74
+ result = { "ok" => true, "revision" => revision, "rollover" => rollover }
75
+ when "prepare"
76
+ unless valid_session_id?(options[:source_session_id])
77
+ result = fail_result("invalid_source_session_id")
78
+ throw :transition_done, result
79
+ end
80
+ if rollover && %w[prepared claimed].include?(rollover["status"])
81
+ result = fail_result("rollover_in_progress", "rollover" => rollover)
82
+ throw :transition_done, result
83
+ end
84
+ rollover_id = "ROL-#{SecureRandom.uuid}"
85
+ relative_path = ".planning/handoffs/#{state.fetch("current_feature")}/#{rollover_id}.json"
86
+ absolute_path, = DevflowControlIO.resolve_control_path(root, relative_path, :handoff)
87
+ payload = {
88
+ "schema_version" => 1,
89
+ "rollover_id" => rollover_id,
90
+ "created_at" => iso_now,
91
+ "reason" => options[:reason].to_s.empty? ? "workflow_step_boundary" : options[:reason],
92
+ "source_session_id" => options[:source_session_id],
93
+ "state" => {
94
+ "revision" => revision,
95
+ "current_flow" => state["current_flow"], "current_feature" => state["current_feature"],
96
+ "current_phase" => state["current_phase"], "current_step" => state["current_step"],
97
+ "status" => state["status"], "completed_steps" => state["completed_steps"], "pending_steps" => state["pending_steps"]
98
+ },
99
+ "references" => {
100
+ "state_path" => ".planning/STATE.md",
101
+ "workflow_path" => state["workflow_config"],
102
+ "artifact_registry" => ".planning/artifacts/index.yaml"
103
+ }
104
+ }
105
+ serialized = JSON.generate(payload) + "\n"
106
+ digest = Digest::SHA256.hexdigest(serialized)
107
+ if options[:write]
108
+ DevflowControlIO.atomic_create(absolute_path, serialized)
109
+ state["rollover"] = {
110
+ "id" => rollover_id, "status" => "prepared", "handoff_path" => relative_path,
111
+ "handoff_sha256" => digest, "source_session_id" => options[:source_session_id],
112
+ "target_session_id" => nil, "expected_state_revision" => revision,
113
+ "prepared_at" => iso_now, "claimed_at" => nil
114
+ }
115
+ state["revision"] = revision + 1
116
+ write_state(state_path, state, raw)
117
+ end
118
+ result = { "ok" => true, "written" => options[:write], "rollover" => options[:write] ? state["rollover"] : payload.merge("handoff_sha256" => digest), "revision" => options[:write] ? revision + 1 : revision }
119
+ when "claim"
120
+ unless rollover.is_a?(Hash) && rollover["status"] == "prepared"
121
+ result = fail_result("rollover_not_prepared", "rollover" => rollover)
122
+ throw :transition_done, result
123
+ end
124
+ unless valid_session_id?(options[:target_session_id])
125
+ result = fail_result("invalid_target_session_id")
126
+ throw :transition_done, result
127
+ end
128
+ path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
129
+ unless File.exist?(path) && Digest::SHA256.hexdigest(File.binread(path)) == rollover["handoff_sha256"]
130
+ result = fail_result("handoff_integrity_failure")
131
+ throw :transition_done, result
132
+ end
133
+ if options[:write]
134
+ rollover["status"] = "claimed"; rollover["target_session_id"] = options[:target_session_id]; rollover["claimed_at"] = iso_now
135
+ state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
136
+ end
137
+ result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
138
+ when "consume"
139
+ unless rollover.is_a?(Hash) && rollover["status"] == "claimed" && valid_session_id?(options[:target_session_id]) && rollover["target_session_id"] == options[:target_session_id]
140
+ result = fail_result("rollover_not_owned_by_target", "rollover" => rollover)
141
+ throw :transition_done, result
142
+ end
143
+ if options[:write]
144
+ rollover["status"] = "consumed"; rollover["consumed_at"] = iso_now
145
+ state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
146
+ end
147
+ result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
148
+ else
149
+ result = fail_result("unknown_action")
150
+ end
151
+ rescue DevflowControlIO::InvalidControlPath, Psych::Exception, SystemCallError, RuntimeError => error
152
+ result = fail_result("rollover_transition_failed", "message" => error.message)
153
+ ensure
154
+ DevflowControlIO.release_lock(lock)
155
+ end
156
+ end
157
+
158
+ puts JSON.pretty_generate(result)
159
+ exit(result["ok"] ? 0 : 1)
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
5
+ require "digest"
5
6
  require "optparse"
6
7
  require "yaml"
7
8
  require_relative "control_io"
@@ -42,6 +43,7 @@ module WorkflowStateValidator
42
43
  REQUIRED_STATE_FIELDS.each do |field|
43
44
  issues << issue("error", "missing_state_field", "STATE.md is missing required field: #{field}", field: field) unless state.key?(field)
44
45
  end
46
+ validate_rollover(issues, root, state)
45
47
  return result(false, state_path, nil, nil, issues) if issues.any? { |entry| entry["severity"] == "error" }
46
48
 
47
49
  ownership = PhaseOwnership.validate_state(root: root, state: state, owner_override: phase_owner_override)
@@ -122,6 +124,40 @@ module WorkflowStateValidator
122
124
  nil
123
125
  end
124
126
 
127
+ def validate_rollover(issues, root, state)
128
+ rollover = state["rollover"]
129
+ return if rollover.nil?
130
+ unless rollover.is_a?(Hash)
131
+ issues << issue("error", "invalid_rollover", "STATE.rollover must be an object")
132
+ return
133
+ end
134
+ status = rollover["status"]
135
+ unless %w[prepared claimed consumed failed].include?(status)
136
+ issues << issue("error", "invalid_rollover_status", "STATE.rollover.status is invalid", status: status)
137
+ return
138
+ end
139
+ %w[id handoff_path handoff_sha256 source_session_id prepared_at].each do |field|
140
+ issues << issue("error", "missing_rollover_field", "STATE.rollover is missing #{field}", field: field) unless rollover[field].is_a?(String) && !rollover[field].empty?
141
+ end
142
+ return if issues.any? { |entry| entry["severity"] == "error" }
143
+ begin
144
+ handoff_path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
145
+ if %w[prepared claimed consumed].include?(status) && !File.exist?(handoff_path)
146
+ issues << issue("error", "missing_rollover_handoff", "STATE.rollover references a missing immutable handoff", path: rollover["handoff_path"])
147
+ elsif File.exist?(handoff_path) && Digest::SHA256.hexdigest(File.binread(handoff_path)) != rollover["handoff_sha256"]
148
+ issues << issue("error", "rollover_handoff_hash_mismatch", "STATE.rollover handoff hash does not match immutable payload", path: rollover["handoff_path"])
149
+ end
150
+ rescue DevflowControlIO::InvalidControlPath => error
151
+ issues << issue("error", "invalid_rollover_handoff_path", error.message)
152
+ end
153
+ if rollover["handoff_sha256"].to_s !~ /\A[0-9a-f]{64}\z/
154
+ issues << issue("error", "invalid_rollover_handoff_hash", "STATE.rollover.handoff_sha256 must be SHA-256 hex")
155
+ end
156
+ if status == "claimed" && (rollover["target_session_id"].to_s.empty? || rollover["claimed_at"].to_s.empty?)
157
+ issues << issue("error", "incomplete_rollover_claim", "Claimed rollover requires target_session_id and claimed_at")
158
+ end
159
+ end
160
+
125
161
  def load_yaml(path, issues, code)
126
162
  unless File.exist?(path)
127
163
  issues << issue("error", "missing_#{code}", "YAML file does not exist", path: path)
@@ -43,9 +43,10 @@ The meta file must also set `traceability_status: "passed"` only after every API
43
43
  1. **只定义接口契约,不谈实现细节** — 不写数据表结构、不写具体代码、不写业务流程内部逻辑
44
44
  2. **分叉后不变更** — 一旦确定,Track A/B 都基于此契约工作。变更走 `api-supplement` 异常路径
45
45
  3. **接口签名必须完整** — 包括 endpoint、method、请求参数(含类型约束)、响应结构(含状态码)、错误契约与行为语义(过滤、排序、权限、幂等/兼容规则)
46
- 4. **数据模型共享** — 核心业务实体定义在这里,前后端共用一个数据模型
47
- 5. **切片对齐** — 若 baseline 包含 `SL-*`,先读取 `skill://workflow-pipeline/references/slice-safety-guide.md`,只为当前执行切片定义 API。后置切片接口不得提前进入契约。
48
- 6. **既有接口优先查事实索引** — 当 `.planning/code-index/` 存在时,先用 Symbol/Relation 定位候选接口、调用方和处理器,再读取源码证据;缺失或过期时再按 navigation 和 `rg` 兜底。
46
+ 4. **负载职责明确** — 事务命令、运营查询和统计查询不得在同一个接口语义中混写;统计查询必须引用已确认的 `METRIC-*`,并在响应中约定 `as_of`/新鲜度语义或明确强一致依据。
47
+ 5. **数据模型共享** — 核心业务实体定义在这里,前后端共用一个数据模型
48
+ 6. **切片对齐** — 若 baseline 包含 `SL-*`,先读取 `skill://workflow-pipeline/references/slice-safety-guide.md`,只为当前执行切片定义 API。后置切片接口不得提前进入契约。
49
+ 7. **既有接口优先查事实索引** — 当 `.planning/code-index/` 存在时,先用 Symbol/Relation 定位候选接口、调用方和处理器,再读取源码证据;缺失或过期时再按 navigation 和 `rg` 兜底。
49
50
 
50
51
  ## 可测试 API 契约(强制)
51
52
 
@@ -15,6 +15,8 @@ description: "Use after API contract and before frontend/backend technical speci
15
15
  - WSH 使用 `workload_shape`,并连接 `REQ/BEH/API → WSH`,relation 为 `shapes_workload`。
16
16
  - WLC 使用 `workload_constraint`,并连接 `BEH/SL/API → WLC`,relation 为 `constrains_workload`。
17
17
  - `unknown` 只能写缺口、责任人、截止 checkpoint;不能填 target、evidence 或 assumption。
18
+ - 每个新增/变更的 API、BEH、Job 至少归类为 `transactional_command`、`operational_query`、`analytics_query`、`analytics_projection`、`batch_processing` 或 `external_integration`;历史节点没有 `archetype` 时按 legacy 处理,不倒逼补录。
19
+ - `analytics_query` 必须关联 `METRIC-*`;`analytics_projection` 还必须关联 `RM-*`。先提交指标口径(粒度、纳入/排除规则、时间字段/时区、修正规则、owner),再提交读模型(事实源、服务 API、投影/刷新方式、重建、迟到数据和对账)。
18
20
  4. 对 `lightweight` 或 `full` 且存在实质取舍的结果,提交 `decision_type: architecture_option_comparison` 的 `DECISION` candidate:
19
21
  - `subject_refs` 只引用 WSH/WLC;WSH 用 `informs_decision` 连到 DECISION,WLC 用 `constrains_decision` 连到 DECISION。
20
22
  - 每个 option 使用 `option_key`,并包含 `mechanism`、非空 `fact_refs`、非空 `tradeoffs`。
@@ -31,6 +33,8 @@ description: "Use after API contract and before frontend/backend technical speci
31
33
  - `assessment_status: incomplete`、`comparison_status: missing` 或 `awaiting_*` 必须原样交给下游技术方案,不能写成“评估通过”。
32
34
  - 已确认 WSH/WLC/架构 DECISION/RISK 不可原地更新;新建 candidate、重新连边,并让 stale 传播要求消费者复核。
33
35
  - 不替用户选择方案、接受残余风险或伪造 CP 记录。
36
+ - 业务事实表是唯一可审计真相源;`RM-*` 是可重建的派生读模型,不能被业务命令当作事实源回写。
37
+ - 不因“统计”自动要求拆库、消息队列或数仓。`direct_query`、物化视图、定时汇总、事件投影和数仓必须根据 WSH/WLC 给出有实质差异的比较与升级条件。
34
38
 
35
39
  ## 下游交接
36
40
 
@@ -48,6 +48,8 @@ allowed-tools:
48
48
 
49
49
  读取 `.planning/requirements/{feature}/traceability.json`。每个设计章节必须列 `覆盖 REQ: REQ-*`;定时任务、批处理、导入和通知至少拆为“编排(扫描/触发/锁/重试)”与“业务算法(判定/计算/写入)”两个可独立验证的小节。不得把遍历全部活跃组织、发送通知、文件解析等编排事实藏在算法描述中。
50
50
 
51
+ 当架构简报包含 `analytics_query`、`analytics_projection`、`METRIC-*` 或 `RM-*` 时,后端方案额外必须分别写明:业务事实源;指标粒度/纳入排除/时区与修正;读模型或直接聚合路径;索引/扫描范围;新鲜度;watermark/迟到数据;重建与对账。不得把统计 SQL 混进事务命令,或以“后续补统计”跳过这些字段。
52
+
51
53
  根据需求基线和项目现有代码,生成聚焦于影响分析的后端技术方案。
52
54
 
53
55
  ## 核心原则
@@ -7,6 +7,14 @@ description: "Use after integration verification and before change planning to d
7
7
 
8
8
  在代码执行前,基于已确认的需求、契约和架构约束提出独立的 `TC-*` 验收测试候选。测试意图必须独立于实现:禁止读取 `SUMMARY.md`、实现源码、git diff 或 agent 交付自述来决定测试范围。
9
9
 
10
+ ## 执行合同
11
+
12
+ 本步骤的图谱写入受 ExecutionContract v2 约束:`devflow_requirement_graph(action="propose", kind="test_case")` 与 `devflow_api_test_matrix(action="import")` 仅在 active contract 的 step 为 `acceptance-test-design` 时放行。步骤开始且无 active contract 时:
13
+
14
+ 1. 先调用 `devflow_contract(action="status")` 检查当前会话是否有 active contract;
15
+ 2. 未激活时调用 `devflow_contract(action="activate", feature="<feature>", step="acceptance-test-design")` 合成并激活(planning / waiting-user / awaiting-approval 阶段均可,Core 校验 STATE 与图谱 revision 后由用户确认);
16
+ 3. 会话中断后 contract 由 runtime 在 `session_start` 时从 `.planning/contracts/{feature}/EC-*.json` 自动恢复;恢复时校验 state/graph revision,推进后自动失效,需重新合成。
17
+
10
18
  ## 输入
11
19
 
12
20
  按顺序读取:
@@ -39,11 +47,15 @@ description: "Use after integration verification and before change planning to d
39
47
  "preconditions": ["身份、数据和环境前置条件"],
40
48
  "steps": ["测试操作"],
41
49
  "oracle": "可观察的预期结果",
42
- "automation_mode": "automated|manual|blocked"
50
+ "automation_mode": "automated|manual|blocked",
51
+ "evidence_strategy": "replay|causal|observation"
43
52
  }
44
53
  ```
45
54
 
46
55
  - `manual` 额外写 `manual_reason` 和 `owner`;`blocked` 额外写 `blocked_by` 和 `owner`。
56
+ - `replay`:可用稳定步骤再次触发,必须写 `replay_scope`(`component|page|api|integration`)。页面优先拆到组件、页面、接口或集成边界,不要求 AI 在真实环境盲目重试。
57
+ - `causal`:用于已由 HAR、控制台、DOM 快照、服务日志或请求链路证明首个偏差、但暂不能稳定重放的缺陷。必须写 `causal_evidence_refs`(已有 `SRC-*` / `CODE-*`)和 `first_divergence`;后续执行绑定还必须提供实际证据文件,Core 会哈希留档。没有这些证据不能称为“已证实”。
58
+ - `observation`:用于当前无法重放也无法证明根因的偶发问题。必须写 `observation_plan` 的 `signal`、`owner`、`window`、`alert_condition`,且 `automation_mode` 必须为 `blocked`。它会阻止版本收口,不能被“人工已看过”替代。
47
59
  - 当 TC 承接结构性专项义务时,额外带对应引用并建立同样的 `verified_by` edge:`api_refs`(API→`api`)、`workload_refs`(WSH/WLC→`performance` 或 `migration`)、`risk_refs`(架构 RISK→`resilience`)。跨数据域的 REQ 必须有 `permission` TC,BC 必须有 `boundary` TC。
48
60
  - 页面/API 的具体 selector、endpoint URL、测试数据和 runner 命令属于后续执行绑定,不得改变本步骤的步骤或 oracle。
49
61
  - 高风险边界、权限、迁移、外部依赖和工作负载事实必须产生相应 TC;`test-obligation-check` 是机器判定,不接受“无架构影响”的模型自述。环境未准备好只能标记 blocked,不得省略。
@@ -36,6 +36,8 @@ description: "Execute the frozen TC acceptance suite after code review. Use the
36
36
  - 通用自动化用例使用 `{ "kind": "command", "argv": [ ... ] }`,不使用 shell 字符串;
37
37
  - Core 生成的 API TC 可直接使用 `{ "kind": "http", "base_url_env": "DEVFLOW_TEST_BASE_URL", "headers_env": { "Authorization": "DEVFLOW_TEST_AUTHORIZATION" } }`。请求、HTTP 状态和异常码断言来自冻结 TC 的 `execution`,绑定只能提供环境地址和鉴权,不得重写测试意图;若场景需要专用夹具,可额外配置 `{ "setup": { "argv": [ ... ] } }`,Core 会先记录并执行该受控命令,再发 HTTP 请求;
38
38
  - 人工或环境阻塞用例必须有 owner 与原因/阻塞源。
39
+ - `evidence_strategy=causal` 的自动用例额外写 `evidence_files`(项目根目录内的 HAR、控制台、DOM 快照、截图或日志文件相对路径)。Core 在执行前校验文件存在且未越出项目根目录,并把内容哈希写入结果;不能只填 Markdown 描述。
40
+ - `evidence_strategy=observation` 只能保持 `blocked`,其观测计划是风险收口输入,不是测试通过证据。
39
41
  5. 调用 `devflow_run_acceptance_tests(action="validate", feature=...)`。不通过时修正绑定,不得绕过。
40
42
  6. 调用 `devflow_run_acceptance_tests(action="run", feature=...)`。该 Core 工具记录每个 TC 的命令哈希、退出码和输出摘要。
41
43
  7. 失败、未执行或 blocked 的 TC 只能报告为 `blocked`/`failed`,不得进入版本收口。