@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
@@ -26,7 +26,13 @@ options = {
26
26
  cancel_reason: nil,
27
27
  expected_revision: nil,
28
28
  json: false,
29
- runtime_confirmed: false
29
+ runtime_confirmed: false,
30
+ confirm_test_prerequisites: false,
31
+ parallel_action: nil,
32
+ parallel_branch_id: nil,
33
+ parallel_attempt: nil,
34
+ parallel_branch_revision: nil,
35
+ parallel_evidence: {}
30
36
  }
31
37
 
32
38
  OptionParser.new do |opts|
@@ -43,6 +49,13 @@ OptionParser.new do |opts|
43
49
  opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
44
50
  opts.on("--json", "Print JSON output") { options[:json] = true }
45
51
  opts.on("--runtime-confirmed", "Internal runtime authorization for checkpoint confirmation") { options[:runtime_confirmed] = true }
52
+ opts.on("--confirm-test-prerequisites", "Confirm displayed CP-3 prerequisite candidates in the same atomic freeze") { options[:confirm_test_prerequisites] = true }
53
+ opts.on("--parallel-action ACTION", %w[start complete fail retry join]) { |value| options[:parallel_action] = value }
54
+ opts.on("--parallel-branch-id ID", "Branch id for a parallel transition") { |value| options[:parallel_branch_id] = value }
55
+ opts.on("--parallel-task-id ID", "OMP task id to bind to a branch") { |value| options[:parallel_task_id] = value }
56
+ opts.on("--parallel-attempt N", Integer) { |value| options[:parallel_attempt] = value }
57
+ opts.on("--parallel-branch-revision N", Integer) { |value| options[:parallel_branch_revision] = value }
58
+ opts.on("--parallel-evidence JSON") { |value| options[:parallel_evidence] = JSON.parse(value) }
46
59
  end.parse!
47
60
 
48
61
  root = Dir.pwd
@@ -83,6 +96,8 @@ def transition_gate_contract(workflow, step)
83
96
  window["branches"].keys.map { |name| name.to_s.sub(/\Atrack_/, "").upcase }
84
97
  end
85
98
  allowed_tracks = track_names.empty? ? ["main"] : ["main", *track_names]
99
+ step_order = step["order"].to_i
100
+ immediate_prior = Array(workflow["steps"]).select { |candidate| candidate.is_a?(Hash) && candidate["order"].to_i < step_order }.max_by { |candidate| candidate["order"].to_i }
86
101
  reentries = (workflow["failure_branches"] || {}).each_with_object([]) do |(source_step, branch), entries|
87
102
  next unless branch.is_a?(Hash) && branch["resume_after_success"].is_a?(Hash)
88
103
  entries << { "source" => { "flow" => branch["workflow"], "step" => source_step }, "target" => branch["resume_after_success"], "requires_checkpoint_reconfirm" => true }
@@ -93,6 +108,7 @@ def transition_gate_contract(workflow, step)
93
108
  "topology" => {
94
109
  "allowed_tracks" => allowed_tracks,
95
110
  "allowed_predecessors" => Array(step["depends_on"]),
111
+ "allowed_intervening_predecessors" => immediate_prior ? [immediate_prior["id"]] : [],
96
112
  "allowed_reentry" => reentries,
97
113
  "checkpoint" => checkpoint && checkpoint.merge("id" => checkpoint_id)
98
114
  },
@@ -409,6 +425,104 @@ def track_boundary?(state, pending_ids)
409
425
  (pending_ids & track_allowed_steps(track)).empty? && pending_ids.any?
410
426
  end
411
427
 
428
+ def parallel_window_for(workflow, completed_step_id)
429
+ Array(workflow["parallel_windows"]).find do |window|
430
+ window.is_a?(Hash) && window["after_step"].to_s == completed_step_id.to_s
431
+ end
432
+ end
433
+
434
+ def parallel_branch_for(root, state, branch_id)
435
+ record = ParallelRun.load(root, state.fetch("current_feature"))
436
+ branch = Array(record && record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
437
+ [record, branch]
438
+ end
439
+
440
+ def advance_parallel_branch(root, state, options, lock = nil)
441
+ branch_id = options[:parallel_branch_id]
442
+ raise ArgumentError, "parallel branch id is required" if branch_id.to_s.empty?
443
+ record, branch = parallel_branch_for(root, state, branch_id)
444
+ raise ArgumentError, "parallel run not found" unless record
445
+ raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
446
+ expected = Integer(record.fetch("run_revision", 0))
447
+ case options[:parallel_action]
448
+ when "start"
449
+ ParallelRun.update_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, status: "running", task_id: options[:parallel_task_id], lock: lock)
450
+ when "complete"
451
+ ParallelRun.update_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, status: "succeeded", evidence_path: options[:parallel_evidence]["evidence_path"], output_fingerprint: options[:parallel_evidence]["output_fingerprint"], lock: lock)
452
+ when "fail"
453
+ # The error is diagnostic metadata only; it is never used as evidence or
454
+ # as an authorization for branch completion.
455
+ ParallelRun.update_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, status: "failed", error: options[:parallel_evidence]["error"], lock: lock)
456
+ when "retry"
457
+ ParallelRun.retry_branch(root: root, feature: state.fetch("current_feature"), branch_id: branch_id, expected_revision: expected, lock: lock)
458
+ else
459
+ raise ArgumentError, "unsupported parallel branch action"
460
+ end
461
+ end
462
+
463
+ def join_parallel_window(root, state, workflow, current_revision)
464
+ record = ParallelRun.load(root, state.fetch("current_feature"))
465
+ raise ArgumentError, "parallel run not found" unless record
466
+ branches = Array(record["branches"])
467
+ incomplete = branches.reject { |branch| branch["status"].to_s == "succeeded" }
468
+ raise ArgumentError, "parallel join requires every branch to succeed" unless branches.any? && incomplete.empty?
469
+
470
+ branch_steps = branches.map { |branch| branch.fetch("workflow_step").to_s }
471
+ state["completed_steps"] = (Array(state["completed_steps"]) + branch_steps).uniq
472
+ state["pending_steps"] = Array(state["pending_steps"]) - branch_steps
473
+ window = workflow.fetch("parallel_windows").find { |candidate| candidate["id"].to_s == record["workflow_window"].to_s }
474
+ raise ArgumentError, "parallel workflow window not found" unless window
475
+ join_step_id = window.fetch("join_step")
476
+ join_step = Array(workflow["steps"]).find { |step| step["id"].to_s == join_step_id.to_s }
477
+ join_predecessors = Array(join_step && join_step["depends_on"]).map(&:to_s)
478
+ # The join step is only becoming current here; it has not completed yet.
479
+ # Keep last_completed_step on an actual branch predecessor so the next
480
+ # GateContract evaluation does not reject the join step as its own previous
481
+ # step. Prefer the final declared dependency for deterministic behavior.
482
+ last_predecessor = join_predecessors.reverse.find { |step_id| branch_steps.include?(step_id) }
483
+ state["last_completed_step"] = last_predecessor || branch_steps.last || state["last_completed_step"]
484
+ state.delete("parallel_run_ref")
485
+ record["status"] = "completed"
486
+ record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
487
+ record["updated_at"] = Time.now.utc.iso8601
488
+ state["current_step"] = join_step_id
489
+ state["status"] = "in_progress"
490
+ state["state_updated_at"] = Date.today.to_s
491
+ state["revision"] = current_revision + 1
492
+ [state, record, "Parallel window joined; branch steps merged and parallel_run_ref cleared."]
493
+ end
494
+
495
+ # Releases states written by the pre-fix join implementation. That version set
496
+ # last_completed_step to the join step while leaving the join step pending,
497
+ # making GateContract reject the very next complete-step call. The signature
498
+ # below is deliberately narrow so unrelated historical state is never changed.
499
+ def repair_legacy_parallel_join_predecessor!(state, workflow)
500
+ return nil if state["parallel_run_ref"]
501
+
502
+ current_step = state["current_step"].to_s
503
+ completed = Array(state["completed_steps"]).map(&:to_s)
504
+ return nil if current_step.empty? || completed.include?(current_step)
505
+ return nil unless state["last_completed_step"].to_s == current_step
506
+
507
+ window = Array(workflow["parallel_windows"]).find { |candidate| candidate["join_step"].to_s == current_step }
508
+ return nil unless window
509
+
510
+ branch_steps = if window["branches"].is_a?(Hash)
511
+ window["branches"].values.flatten.map(&:to_s)
512
+ else
513
+ Array(window["branch_steps"]).map(&:to_s)
514
+ end
515
+ return nil if branch_steps.empty? || branch_steps.any? { |step_id| !completed.include?(step_id) }
516
+
517
+ join_step = Array(workflow["steps"]).find { |step| step["id"].to_s == current_step }
518
+ predecessor = Array(join_step && join_step["depends_on"]).map(&:to_s).reverse.find { |step_id| completed.include?(step_id) }
519
+ predecessor ||= branch_steps.reverse.find { |step_id| completed.include?(step_id) }
520
+ return nil unless predecessor
521
+
522
+ state["last_completed_step"] = predecessor
523
+ predecessor
524
+ end
525
+
412
526
  def fail_result(reason, details = {})
413
527
  { "ok" => false, "reason" => reason, "details" => details }
414
528
  end
@@ -457,6 +571,66 @@ rescue JSON::ParserError => error
457
571
  { "ok" => false, "errors" => ["API test schema checker returned invalid JSON: #{error.message}"] }
458
572
  end
459
573
 
574
+ def cross_track_checker_enabled?(workflow, step_id)
575
+ Array(workflow.dig("governance", "step_checkers", step_id)).map(&:to_s).include?("cross-track-consistency-check")
576
+ end
577
+
578
+ def cross_track_consistency_check(root, feature)
579
+ script = File.join(__dir__, "cross_track_consistency_check.rb")
580
+ stdout, stderr, status = Open3.capture3("ruby", script, "--feature", feature, "--json", chdir: root)
581
+ payload = JSON.parse(stdout)
582
+ return payload if status.success? && payload.is_a?(Hash)
583
+
584
+ { "ok" => false, "errors" => [stderr.strip.empty? ? "cross-track checker returned invalid output" : stderr.strip] }
585
+ rescue JSON::ParserError => error
586
+ { "ok" => false, "errors" => ["cross-track checker returned invalid JSON: #{error.message}"] }
587
+ end
588
+
589
+ def business_cross_model_checker_enabled?(workflow, step_id)
590
+ Array(workflow.dig("governance", "step_checkers", step_id)).map(&:to_s).include?("business-cross-model-check")
591
+ end
592
+
593
+ def business_cross_model_check(root, feature)
594
+ script = File.join(__dir__, "business_cross_model_check.py")
595
+ business = ".planning/business-model/#{feature}/canonical.json"
596
+ wireframe = ".planning/features/#{feature}/canonical.json"
597
+ unless File.file?(File.join(root, business))
598
+ return { "valid" => false, "errors" => [{ "code" => "BUSINESS_CANONICAL_MISSING", "path" => business }] }
599
+ end
600
+ command = ["python3", script, "--business-canonical", business]
601
+ command += ["--wireframe", wireframe] if File.file?(File.join(root, wireframe))
602
+ claims = ".planning/features/#{feature}/business-claims.yaml"
603
+ command += ["--claims", claims] if File.file?(File.join(root, claims))
604
+ traceability = ".planning/requirements/#{feature}/traceability.json"
605
+ command += ["--traceability", traceability] if File.file?(File.join(root, traceability))
606
+ stdout, stderr, status = Open3.capture3(*command, "--json", chdir: root)
607
+ payload = JSON.parse(stdout)
608
+ return payload if status.success? && payload.is_a?(Hash)
609
+
610
+ payload.is_a?(Hash) ? payload : { "valid" => false, "errors" => [{ "code" => "BUSINESS_CROSS_MODEL_CHECK_FAILED", "message" => stderr.strip }] }
611
+ rescue JSON::ParserError => error
612
+ { "valid" => false, "errors" => [{ "code" => "BUSINESS_CROSS_MODEL_CHECK_INVALID", "message" => error.message }] }
613
+ end
614
+
615
+ def branch_machine_evidence(root, state, branch)
616
+ script = File.join(__dir__, "machine_evidence.rb")
617
+ step = branch.fetch("workflow_step").to_s
618
+ attempt = Integer(branch.fetch("attempt", 0))
619
+ revision = Integer(branch.fetch("branch_revision", 0))
620
+ stdout, stderr, status = Open3.capture3(
621
+ "ruby", script, "--feature", state.fetch("current_feature"), "--step", step,
622
+ "--parallel-branch-id", branch.fetch("branch_id").to_s,
623
+ "--parallel-attempt", attempt.to_s, "--parallel-branch-revision", revision.to_s,
624
+ "--lock-held", "--write", "--json", chdir: root
625
+ )
626
+ payload = JSON.parse(stdout)
627
+ return payload if status.success? && payload.is_a?(Hash) && payload["ok"] == true
628
+
629
+ { "ok" => false, "errors" => [stderr.strip.empty? ? "machine evidence returned invalid output" : stderr.strip] }
630
+ rescue JSON::ParserError => error
631
+ { "ok" => false, "errors" => ["machine evidence returned invalid JSON: #{error.message}"] }
632
+ end
633
+
460
634
  def test_suite_freeze_attestation_path(root, feature)
461
635
  DevflowControlIO.resolve_control_path(root, ".planning/features/#{feature}/test-suite-freeze.json", :artifact)
462
636
  end
@@ -565,9 +739,17 @@ result =
565
739
  next_intent_written = nil
566
740
  actions = []
567
741
  action_description = nil
742
+ parallel_run_to_write = nil
743
+ test_suite_freeze_to_write = nil
568
744
  next_intent_requested = false
569
745
  current_revision = Integer(state.fetch("revision", 0))
570
746
 
747
+ if (repaired_predecessor = repair_legacy_parallel_join_predecessor!(state, workflow))
748
+ changed = true
749
+ actions << "legacy_parallel_join_predecessor_repaired"
750
+ action_description = "Repaired legacy parallel join predecessor: #{repaired_predecessor}."
751
+ end
752
+
571
753
  if result.nil? && !options[:expected_revision].nil? && current_revision != options[:expected_revision]
572
754
  result = fail_result(
573
755
  "stale_revision",
@@ -576,13 +758,40 @@ result =
576
758
  )
577
759
  end
578
760
 
761
+ # Branch completion evidence is always generated by Core from the
762
+ # persisted branch record and current artifacts. Ignore any caller-built
763
+ # binding so an agent cannot self-attest a different attempt/revision.
764
+ if result.nil? && options[:parallel_action] == "complete" && options[:write]
765
+ _run, branch = parallel_branch_for(root, state, options[:parallel_branch_id])
766
+ if branch.nil?
767
+ result = fail_result("parallel_branch_not_found")
768
+ else
769
+ generated = branch_machine_evidence(root, state, branch)
770
+ if generated["ok"] == true
771
+ binding = generated.fetch("parallel_binding")
772
+ options[:parallel_evidence] = {
773
+ "evidence_path" => generated.fetch("path"),
774
+ "branch_id" => binding.fetch("branch_id"),
775
+ "attempt" => binding.fetch("attempt"),
776
+ "branch_revision" => binding.fetch("branch_revision"),
777
+ "state_revision" => binding.fetch("state_revision")
778
+ }
779
+ else
780
+ result = fail_result("parallel_machine_evidence_failed", "evidence" => generated)
781
+ end
782
+ end
783
+ end
784
+
579
785
  # GateRuntime is a read-only boundary check. It runs while the project
580
786
  # lock is held and before any candidate state mutation; the existing
581
787
  # validator and advance_state CAS remain the final write guard.
582
788
  if result.nil? && gate_contract_enabled?(workflow) &&
583
789
  !%w[prepared claimed].include?(state.dig("rollover", "status")) &&
584
- (options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch])
585
- target_step_id = if options[:complete_step]
790
+ (options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch] || options[:parallel_action])
791
+ target_step_id = if options[:parallel_action] && options[:parallel_action] != "join"
792
+ _record, branch = parallel_branch_for(root, state, options[:parallel_branch_id])
793
+ branch && branch["workflow_step"]
794
+ elsif options[:complete_step]
586
795
  options[:complete_step]
587
796
  elsif options[:confirm_checkpoint]
588
797
  state["current_step"]
@@ -595,20 +804,78 @@ result =
595
804
  elsif options[:complete_step] && state["current_step"].to_s != target_step_id.to_s
596
805
  result = fail_result("gate_current_step_mismatch", "current_step" => state["current_step"], "requested_step" => target_step_id)
597
806
  else
598
- transition_kind = if options[:confirm_checkpoint]
807
+ transition_kind = if options[:parallel_action]
808
+ { "start" => "parallel_branch_start", "complete" => "parallel_branch_complete", "fail" => "parallel_branch_fail", "retry" => "parallel_branch_retry", "join" => "parallel_join" }.fetch(options[:parallel_action])
809
+ elsif options[:confirm_checkpoint]
599
810
  "confirm_checkpoint"
600
811
  elsif options[:enter_failure_branch]
601
812
  "enter_failure_branch"
602
813
  elsif options[:exit_failure_branch]
603
814
  "exit_failure_branch"
604
815
  end
605
- gate_result = transition_gate_preflight(workflow, state, target_step, current_revision, options[:runtime_confirmed], transition_kind)
816
+ gate_result = GateRuntime.evaluate(
817
+ contract: transition_gate_contract(workflow, target_step),
818
+ workflow_state: state,
819
+ current_revision: current_revision,
820
+ expected_revision: current_revision,
821
+ track: if options[:parallel_action] && options[:parallel_action] != "join"
822
+ _record, branch = parallel_branch_for(root, state, options[:parallel_branch_id])
823
+ branch ? branch["track"].to_s.sub(/\Atrack_/, "").upcase : (active_track(state) || "main")
824
+ else
825
+ active_track(state) || "main"
826
+ end,
827
+ branch_id: options[:parallel_branch_id],
828
+ attempt: options[:parallel_attempt],
829
+ branch_revision: options[:parallel_branch_revision],
830
+ evidence: options[:parallel_evidence],
831
+ source: "advance_state",
832
+ root: root,
833
+ check_outputs: target_step["id"].to_s == state["current_step"].to_s,
834
+ transition_kind: transition_kind,
835
+ checkpoint_authorized: options[:runtime_confirmed]
836
+ )
606
837
  if gate_result && gate_result["verdict"] != "allow"
607
838
  result = fail_result("gate_boundary_rejected", "gate" => gate_result)
608
839
  end
609
840
  end
610
841
  end
611
842
 
843
+ if result.nil? && options[:parallel_action]
844
+ if !options[:write]
845
+ result = fail_result("parallel_transition_requires_write")
846
+ elsif options[:parallel_action] == "join"
847
+ begin
848
+ if cross_track_checker_enabled?(workflow, "integration-verify")
849
+ checker = cross_track_consistency_check(root, state.fetch("current_feature"))
850
+ unless checker["ok"] == true
851
+ result = fail_result("cross_track_consistency_failed", "checker" => checker)
852
+ end
853
+ end
854
+ if result.nil? && business_cross_model_checker_enabled?(workflow, "integration-verify")
855
+ checker = business_cross_model_check(root, state.fetch("current_feature"))
856
+ unless checker["valid"] == true
857
+ result = fail_result("business_cross_model_check_failed", "checker" => checker)
858
+ end
859
+ end
860
+ if result.nil?
861
+ state, parallel_run_to_write, action_description = join_parallel_window(root, state, workflow, current_revision)
862
+ changed = true
863
+ actions << "parallel_window_joined"
864
+ end
865
+ rescue ArgumentError, KeyError, TypeError => error
866
+ result = fail_result("parallel_join_failed", "message" => error.message)
867
+ end
868
+ else
869
+ begin
870
+ advance_parallel_branch(root, state, options, project_lock)
871
+ actions << "parallel_branch_#{options[:parallel_action]}"
872
+ action_description = "Parallel branch #{options[:parallel_action]}: #{options[:parallel_branch_id]}"
873
+ rescue ArgumentError, KeyError, TypeError => error
874
+ result = fail_result("parallel_branch_transition_failed", "message" => error.message)
875
+ end
876
+ end
877
+ end
878
+
612
879
  if result.nil? && options[:checkpoint_decision]
613
880
  state["checkpoint_decision"] = options[:checkpoint_decision]
614
881
  changed = true
@@ -675,6 +942,12 @@ result =
675
942
  state["checkpoint"] = step["checkpoint"]
676
943
  state["checkpoint_label"] = checkpoint_label(workflow, step["checkpoint"])
677
944
  action_description = "Step completed: #{step["label"]} (#{step_id}). Now waiting at #{step["checkpoint"]}."
945
+ elsif (parallel_window = parallel_window_for(workflow, step_id))
946
+ state["current_step"] = parallel_window.fetch("join_step")
947
+ state["status"] = "in_progress"
948
+ state.delete("checkpoint")
949
+ state.delete("checkpoint_label")
950
+ action_description = "Step completed: #{step["label"]} (#{step_id}). Entered parallel window #{parallel_window["id"]}; awaiting Core prepare."
678
951
  elsif track_boundary?(state, pending)
679
952
  state["current_step"] = step_id
680
953
  state["status"] = "blocked"
@@ -784,21 +1057,25 @@ result =
784
1057
  unless candidate_check["ok"]
785
1058
  result = fail_result("acceptance_test_suite_candidate_binding_failed", "test_suite" => candidate_check)
786
1059
  else
787
- frozen = RequirementGraph.freeze_test_cases!(root, graph, "CP-3: state revision #{current_revision} approved")
1060
+ frozen = RequirementGraph.freeze_test_cases!(
1061
+ root,
1062
+ graph,
1063
+ "CP-3: state revision #{current_revision} approved",
1064
+ confirm_prerequisites: options[:confirm_test_prerequisites] && options[:runtime_confirmed],
1065
+ persist: false
1066
+ )
788
1067
  unless frozen["ok"]
789
1068
  result = fail_result("acceptance_test_suite_freeze_failed", "test_suite" => frozen)
790
1069
  else
791
- begin
792
- write_test_suite_freeze_attestation(root, feature, current_revision, candidate_check, frozen)
793
- frozen_check = test_task_binding_check(root, feature, "frozen")
794
- if frozen_check["ok"]
795
- actions << "acceptance_test_suite_frozen"
796
- else
797
- result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
798
- end
799
- rescue DevflowControlIO::InvalidControlPath, SystemCallError => error
800
- result = fail_result("acceptance_test_suite_freeze_attestation_failed", "message" => error.message)
801
- end
1070
+ actions << "acceptance_test_prerequisites_confirmed" unless Array(frozen["confirmed_prerequisite_ids"]).empty?
1071
+ test_suite_freeze_to_write = {
1072
+ "feature" => feature,
1073
+ "state_revision" => current_revision,
1074
+ "graph" => graph,
1075
+ "candidate_check" => candidate_check,
1076
+ "frozen" => frozen
1077
+ }
1078
+ actions << "acceptance_test_suite_frozen"
802
1079
  end
803
1080
  end
804
1081
  end
@@ -914,22 +1191,49 @@ result =
914
1191
  if !ownership["ok"]
915
1192
  result = fail_result("phase_ownership_claim_failed", "ownership" => ownership)
916
1193
  else
917
- sync_artifact_registry(root, workflow, state, registry: candidate_registry)
918
- artifact_by_path = load_artifact_registry(root, workflow)
919
- write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
920
- # STATE.md is project-global. Once its top-level workflow is
921
- # terminal, retain the historical owner but release its active
922
- # reservation so the next feature can claim a distinct phase.
923
- if %w[completed failed cancelled].include?(state["status"]) && Array(state["pending_steps"]).empty? && state["source_flow"].nil?
924
- completed_owner = PhaseOwnership.complete(
925
- root: root, feature: state["current_feature"], phase: state["current_phase"],
926
- flow: state["current_flow"], write: true
1194
+ if test_suite_freeze_to_write
1195
+ pending_freeze = test_suite_freeze_to_write
1196
+ RequirementGraph.persist_test_suite_freeze!(
1197
+ root,
1198
+ pending_freeze.fetch("graph"),
1199
+ pending_freeze.fetch("frozen").fetch("audit_event")
1200
+ )
1201
+ pending_freeze.fetch("frozen")["tests_view"] = pending_freeze.fetch("graph").dig("views", "tests")
1202
+ write_test_suite_freeze_attestation(
1203
+ root,
1204
+ pending_freeze.fetch("feature"),
1205
+ pending_freeze.fetch("state_revision"),
1206
+ pending_freeze.fetch("candidate_check"),
1207
+ pending_freeze.fetch("frozen")
1208
+ )
1209
+ frozen_check = test_task_binding_check(root, pending_freeze.fetch("feature"), "frozen")
1210
+ unless frozen_check["ok"]
1211
+ result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
1212
+ end
1213
+ end
1214
+ unless result
1215
+ sync_artifact_registry(root, workflow, state, registry: candidate_registry)
1216
+ artifact_by_path = load_artifact_registry(root, workflow)
1217
+ write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
1218
+ ParallelRun.write(root, state.fetch("current_feature"), parallel_run_to_write) if parallel_run_to_write
1219
+ ParallelRun.rebase_state_revision(
1220
+ root: root, feature: state.fetch("current_feature"),
1221
+ state_revision: state.fetch("revision"), lock: project_lock
927
1222
  )
928
- unless completed_owner["ok"]
929
- result = fail_result("phase_ownership_completion_failed", "ownership" => completed_owner)
1223
+ # STATE.md is project-global. Once its top-level workflow is
1224
+ # terminal, retain the historical owner but release its active
1225
+ # reservation so the next feature can claim a distinct phase.
1226
+ if %w[completed failed cancelled].include?(state["status"]) && Array(state["pending_steps"]).empty? && state["source_flow"].nil?
1227
+ completed_owner = PhaseOwnership.complete(
1228
+ root: root, feature: state["current_feature"], phase: state["current_phase"],
1229
+ flow: state["current_flow"], write: true
1230
+ )
1231
+ unless completed_owner["ok"]
1232
+ result = fail_result("phase_ownership_completion_failed", "ownership" => completed_owner)
1233
+ end
930
1234
  end
1235
+ next_intent_written = write_next_intent(root, workflow) if next_intent_requested
931
1236
  end
932
- next_intent_written = write_next_intent(root, workflow) if next_intent_requested
933
1237
  end
934
1238
  end
935
1239
 
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env python3
2
+ """Validate business/technical claims against frozen business facts."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def load_yaml(path: Path) -> Any:
13
+ try:
14
+ import yaml # type: ignore
15
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
16
+ except ImportError:
17
+ result = subprocess.run(["ruby", "-ryaml", "-rjson", "-e", "puts JSON.generate(YAML.safe_load(File.read(ARGV.fetch(0))))", str(path)], text=True, capture_output=True, check=False)
18
+ if result.returncode != 0:
19
+ raise ValueError(result.stderr.strip() or "YAML parse failed")
20
+ return json.loads(result.stdout)
21
+
22
+
23
+ def main() -> int:
24
+ parser = argparse.ArgumentParser()
25
+ parser.add_argument("--claims", type=Path, required=True)
26
+ parser.add_argument("--business-canonical", type=Path, required=True)
27
+ parser.add_argument("--traceability", type=Path)
28
+ args = parser.parse_args()
29
+ try:
30
+ claims = load_yaml(args.claims)
31
+ business = json.loads(args.business_canonical.read_text(encoding="utf-8"))
32
+ traceability = json.loads(args.traceability.read_text(encoding="utf-8")) if args.traceability else {}
33
+ except Exception as error:
34
+ print(json.dumps({"valid": False, "verdict": "block_unsafe", "errors": [{"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "message": str(error)}]}, ensure_ascii=False))
35
+ return 1
36
+ errors = []
37
+ warnings = []
38
+ actions = {item.get("id") for item in business.get("actions", []) if isinstance(item, dict)}
39
+ requirements = {item.get("id") for item in traceability.get("requirements", []) if isinstance(item, dict) and item.get("status") in {"confirmed", "approved"}} if isinstance(traceability, dict) else set()
40
+ values = claims.get("claims") if isinstance(claims, dict) else claims
41
+ if not isinstance(values, list):
42
+ errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": "claims", "message": "claims must be an array"})
43
+ values = []
44
+ for index, claim in enumerate(values):
45
+ path = f"claims[{index}]"
46
+ if not isinstance(claim, dict):
47
+ errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": path, "message": "claim must be an object"})
48
+ continue
49
+ allowed = {"id", "text", "kind", "business_action_ref", "requirement_ref"}
50
+ for key in claim:
51
+ if key not in allowed:
52
+ errors.append({"code": "BUSINESS_CLAIM_UNKNOWN_FIELD", "path": f"{path}.{key}", "message": "unknown claim field"})
53
+ if not isinstance(claim.get("id"), str) or not claim["id"].strip() or not isinstance(claim.get("text"), str) or not claim["text"].strip():
54
+ errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": path, "message": "id and text are required"})
55
+ if isinstance(claim.get("id"), str) and sum(1 for item in values[:index] if isinstance(item, dict) and item.get("id") == claim.get("id")):
56
+ errors.append({"code": "BUSINESS_CLAIM_DUPLICATE_ID", "path": f"{path}.id", "message": f"duplicate claim id {claim['id']}"})
57
+ kind = claim.get("kind")
58
+ if kind not in {"business", "technical"}:
59
+ errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": f"{path}.kind", "message": "kind must be business or technical"})
60
+ ref = claim.get("business_action_ref")
61
+ req = claim.get("requirement_ref")
62
+ if ref and ref not in actions:
63
+ errors.append({"code": "BUSINESS_CLAIM_REF_UNRESOLVED", "path": f"{path}.business_action_ref", "message": f"unknown business action {ref}"})
64
+ if req and (not args.traceability or req not in requirements):
65
+ errors.append({"code": "BUSINESS_CLAIM_REF_UNRESOLVED", "path": f"{path}.requirement_ref", "message": f"requirement is not confirmed or traceability input is missing: {req}"})
66
+ if kind == "business" and not ref and not req:
67
+ errors.append({"code": "BUSINESS_CLAIM_REF_MISSING", "path": path, "message": "business claim requires action or requirement reference"})
68
+ if kind == "technical" and not ref and not req:
69
+ warnings.append({"code": "TECHNICAL_CLAIM_UNGROUNDED", "path": path, "message": "technical claim has no business grounding reference"})
70
+ verdict = "block_unsafe" if errors else ("await_user" if warnings else "allow")
71
+ payload = {"valid": not errors, "verdict": verdict, "errors": errors, "warnings": warnings}
72
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
73
+ return 0 if verdict in {"allow", "await_user"} else 1
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())