@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.
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +728 -39
- package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
- package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +334 -30
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +84 -6
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
- package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- 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[:
|
|
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[:
|
|
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 =
|
|
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!(
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
|
|
929
|
-
|
|
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())
|